From 16a6fa946a9f245fa3347d3ff7107ae8dd79eb61 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 19 Mar 2026 17:51:55 +1100 Subject: [PATCH 01/36] set updating on click --- src/widgets/forms.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/widgets/forms.js b/src/widgets/forms.js index 4c69ecd09..7cfdc84a6 100644 --- a/src/widgets/forms.js +++ b/src/widgets/forms.js @@ -1879,7 +1879,14 @@ export function buildCheckboxForm (dom, kb, lab, del, ins, form, dataDoc, trista refresh() if (!editable) return box + let isUpdating = false // Prevent concurrent updates on double-click + const boxHandler = function (_e) { + if (isUpdating) { + return // Ignore clicks while update is in progress + } + isUpdating = true + input.disabled = true // Disable button to provide user feedback colorCarrier.style.color = '#bbb' // grey -- not saved yet const toDelete = input.state === true ? ins : input.state === false ? del : [] input.newState = @@ -1900,6 +1907,8 @@ export function buildCheckboxForm (dom, kb, lab, del, ins, form, dataDoc, trista success, errorBody ) { + isUpdating = false + input.disabled = false if (!success) { if (toDelete.why) { const hmmm = kb.holds( From 70f846efe3ec0aeae20d21878ce4280999683f0b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 06:50:19 +1100 Subject: [PATCH 02/36] add test for double click --- test/unit/widgets/forms/index.test.ts | 53 ++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/test/unit/widgets/forms/index.test.ts b/test/unit/widgets/forms/index.test.ts index c98d77128..93bc1f665 100644 --- a/test/unit/widgets/forms/index.test.ts +++ b/test/unit/widgets/forms/index.test.ts @@ -1,5 +1,5 @@ import { silenceDebugMessages } from '../../helpers/debugger' -import { namedNode } from 'rdflib' +import { namedNode, st } from 'rdflib' import ns from '../../../../src/ns' import { store } from 'solid-logic' @@ -594,6 +594,57 @@ describe('buildCheckboxForm', () => { ) ).toBeInstanceOf(HTMLDivElement) }) + + it('ignores rapid second click while async update is in progress and reenables button afterward', async () => { + const dataDoc = namedNode('http://example.com/#doc') + const form = namedNode('http://example.com/#form') + const subject = namedNode('http://example.com/#subject') + const predicate = namedNode('http://example.com/#predicate') + const object = namedNode('http://example.com/#object') + const statement = st(subject, predicate, object, dataDoc) + + const originalEditable = store.updater.editable + const originalUpdate = store.updater.update + + const updateSpy = jest.fn((_deletes, _inserts, callback) => { + return new Promise(resolve => { + setTimeout(() => { + callback('uri', true, 'ok') + resolve(true) + }, 0) + }) + }) + + store.updater.editable = jest.fn(() => true) as any + store.updater.update = updateSpy as any + + try { + const box = buildCheckboxForm( + document, + store, + 'label', + [], + statement, + form, + dataDoc, + false + ) + const checkboxButton = box.querySelector('button') as HTMLButtonElement + + checkboxButton.click() + checkboxButton.click() + + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(checkboxButton.disabled).toEqual(true) + + await new Promise(resolve => setTimeout(resolve, 5)) + + expect(checkboxButton.disabled).toEqual(false) + } finally { + store.updater.editable = originalEditable + store.updater.update = originalUpdate + } + }) }) describe('newThing', () => { From d36adb010410d5a20d3a663031b15baf86fffe7c Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 19:29:57 +1100 Subject: [PATCH 03/36] checked login status --- src/login/login.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index b93e07f11..e4af9578c 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1047,10 +1047,15 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { + const currentUser = authn.currentUser() + if (!currentUser) { + return [] + } + try { - const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({}) + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError) + return [] } return solidLogicSingleton.store.each( me, From d1379782bbb8089f1eee37ee9628ffa3b958993f Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 20:22:27 +1100 Subject: [PATCH 04/36] still throw if no pref or error --- src/login/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login/login.ts b/src/login/login.ts index e4af9578c..ba0c7e118 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1055,7 +1055,7 @@ export async function getUserRoles (): Promise> { try { const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) if (!preferencesFile || preferencesFileError) { - return [] + throw new Error(preferencesFileError) } return solidLogicSingleton.store.each( me, From 6e9b8a04153dbdbce3748d176766486f1bdd3241 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 20:43:33 +1100 Subject: [PATCH 05/36] Update src/login/login.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/login/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login/login.ts b/src/login/login.ts index ba0c7e118..183554946 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1055,7 +1055,7 @@ export async function getUserRoles (): Promise> { try { const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError) + throw new Error(preferencesFileError || 'Unable to load user preferences file.') } return solidLogicSingleton.store.each( me, From 05965e010fa85012a8c92160196499be73fd1f09 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 20:44:30 +1100 Subject: [PATCH 06/36] unit test --- test/unit/login/login.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit/login/login.test.ts b/test/unit/login/login.test.ts index 455c99de2..ceb14bb46 100644 --- a/test/unit/login/login.test.ts +++ b/test/unit/login/login.test.ts @@ -11,3 +11,29 @@ describe('ensureLoggedIn', () => { expect(testLogin.ensureLoggedIn({})).toBeInstanceOf(Object) }) }) + +describe('getUserRoles', () => { + afterEach(() => { + jest.restoreAllMocks() + jest.resetModules() + }) + + it('returns [] and does not load preferences when current user is missing', async () => { + const solidLogic = require('solid-logic') + + const currentUserSpy = jest + .spyOn(solidLogic.authn, 'currentUser') + .mockReturnValue(null) + const loadPreferencesSpy = jest.spyOn( + solidLogic.solidLogicSingleton.profile, + 'loadPreferences' + ) + + const loginModule = require('../../../src/login/login') + const roles = await loginModule.getUserRoles() + + expect(currentUserSpy).toHaveBeenCalled() + expect(roles).toEqual([]) + expect(loadPreferencesSpy).not.toHaveBeenCalled() + }) +}) From db5bcefd3cc893bc30e294395ddb9e5290cb66b7 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 13:35:59 +1100 Subject: [PATCH 07/36] return when not logged in --- src/login/login.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/login/login.ts b/src/login/login.ts index e4af9578c..bee9ce311 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1047,6 +1047,11 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { + const sessionInfo = authSession.info + if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { + return [] + } + const currentUser = authn.currentUser() if (!currentUser) { return [] From 51d40cf20662f072f7be55ae69ae8e6e73152d91 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 29 Mar 2026 20:58:18 +1100 Subject: [PATCH 08/36] fix test --- test/unit/login/login.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/unit/login/login.test.ts b/test/unit/login/login.test.ts index ceb14bb46..dc94b1fa0 100644 --- a/test/unit/login/login.test.ts +++ b/test/unit/login/login.test.ts @@ -21,6 +21,11 @@ describe('getUserRoles', () => { it('returns [] and does not load preferences when current user is missing', async () => { const solidLogic = require('solid-logic') + solidLogic.authSession.info = { + isLoggedIn: true, + webId: 'https://alice.example.com/profile/card#me' + } + const currentUserSpy = jest .spyOn(solidLogic.authn, 'currentUser') .mockReturnValue(null) From 96842f8151fec3cf09044231229965d046e99f78 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 05:15:37 +1000 Subject: [PATCH 09/36] foundation of shared select component --- src/v2/components/selectShared/keyboard.ts | 0 .../components/selectShared/listboxStyles.ts | 78 +++++++++++++++++++ .../selectShared/listboxTemplate.ts | 54 +++++++++++++ src/v2/components/selectShared/optionTypes.ts | 5 ++ 4 files changed, 137 insertions(+) create mode 100644 src/v2/components/selectShared/keyboard.ts create mode 100644 src/v2/components/selectShared/listboxStyles.ts create mode 100644 src/v2/components/selectShared/listboxTemplate.ts create mode 100644 src/v2/components/selectShared/optionTypes.ts diff --git a/src/v2/components/selectShared/keyboard.ts b/src/v2/components/selectShared/keyboard.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/v2/components/selectShared/listboxStyles.ts b/src/v2/components/selectShared/listboxStyles.ts new file mode 100644 index 000000000..ea342eaf1 --- /dev/null +++ b/src/v2/components/selectShared/listboxStyles.ts @@ -0,0 +1,78 @@ +import { css } from 'lit' + +export const listboxStyles = css` + :host { // default theme + display: inline-block; + position: relative; + z-index: 200; + --input-background: var(--color-background, #F8F9FB); + --item-text: var(--color-text, #1A1A1A); + --item-hover-background: var(--lavender-900, #7c4cff); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: 200; + --input-background: var(--color-background, #F8F9FB); + --item-text: var(--color-text, #1A1A1A); + --item-hover-background: var(--lavender-900, #7c4cff); + } + + .listbox { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + border: 1px solid var(--color-border, #E5E7EB); + border-top: none; + border-radius: 0 0 var(--border-radius-base, 0.3125rem) var(--border-radius-base, 0.3125rem); + background: var(--input-background); + overflow: visible; + z-index: 10; + box-shadow: 0 4px 12px rgba(124, 77, 255, 0.12); + } + + .listbox-item { + display: block; + width: 100%; + padding: 0.625rem 0.75rem; + border: none; + border-bottom: 1px solid var(--color-border, #E5E7EB); + background: transparent; + color: var(--item-text); + cursor: pointer; + font: inherit; + text-align: left; + box-sizing: border-box; + } + + .listbox-item:last-child { + border-bottom: none; + } + + .listbox-item:hover { + background: var(--item-hover-background); + border-radius: var(--border-radius-base-md, 0.5rem); + } + + .listbox-item-active { + background: var(--item-hover-background); + border-radius: var(--border-radius-base-md, 0.5rem); + outline: none; + } + + .listbox-item-selected { + font-weight: var(--font-weight-bold, 600); + } + + .listbox-item-disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .listbox-item-disabled:hover { + background: transparent; + border-radius: 0; + } +` diff --git a/src/v2/components/selectShared/listboxTemplate.ts b/src/v2/components/selectShared/listboxTemplate.ts new file mode 100644 index 000000000..b441d14c3 --- /dev/null +++ b/src/v2/components/selectShared/listboxTemplate.ts @@ -0,0 +1,54 @@ +import { html } from 'lit' +import type { SelectOption } from './optionTypes' + +export interface RenderListboxArgs { + options: SelectOption[] + selectedOption?: SelectOption + activeOption?: SelectOption + listboxId?: string + getOptionId?: (option: SelectOption, index: number) => string + onOptionSelect: (option: SelectOption) => void +} + +export function renderListbox(args: RenderListboxArgs) { + const { + options, + selectedOption, + activeOption, + listboxId, + getOptionId, + onOptionSelect + } = args + + return html` +
    + ${options.map((option, index) => { + const isSelected = option.value === selectedOption?.value + const isActive = option.value === activeOption?.value + const optionId = getOptionId?.(option, index) + + return html` +
  • + ${option.label} +
  • + ` + })} +
+ ` +} \ No newline at end of file diff --git a/src/v2/components/selectShared/optionTypes.ts b/src/v2/components/selectShared/optionTypes.ts new file mode 100644 index 000000000..7aaa36d8d --- /dev/null +++ b/src/v2/components/selectShared/optionTypes.ts @@ -0,0 +1,5 @@ +export interface SelectOption { + label: string + value: string + disabled?: boolean +} From 21d4030f0e7e80767321e928c2ec5730e27d3a24 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 14:06:53 +1000 Subject: [PATCH 10/36] Add a select webcomponent --- README.md | 4 +- src/v2/components/select/README.md | 202 ++++++++++ src/v2/components/select/Select.test.ts | 150 +++++++ src/v2/components/select/Select.ts | 376 ++++++++++++++++++ src/v2/components/select/index.ts | 9 + src/v2/components/selectShared/keyboard.ts | 82 ++++ .../components/selectShared/listboxStyles.ts | 54 +-- .../selectShared/listboxTemplate.ts | 2 + webpack.config.mjs | 3 + 9 files changed, 858 insertions(+), 24 deletions(-) create mode 100644 src/v2/components/select/README.md create mode 100644 src/v2/components/select/Select.test.ts create mode 100644 src/v2/components/select/Select.ts create mode 100644 src/v2/components/select/index.ts diff --git a/README.md b/README.md index 6d2e8d53c..0ca9b43b0 100644 --- a/README.md +++ b/README.md @@ -411,4 +411,6 @@ You are logged in as nameOfLoggedIn user. * Raptor mini: add a readme to the Footer component with example. -* Claude Sonnet 4.6: Make the dop down as a list under the input field and entlarge the pop up, make it higher, adjustable to fit the drop down. And make the drop down arrow area larger \ No newline at end of file +* Claude Sonnet 4.6: Make the dop down as a list under the input field and entlarge the pop up, make it higher, adjustable to fit the drop down. And make the drop down arrow area larger + +* GPT-5.4: can you wire up the keyboard interactions and aria attributes for Select. \ No newline at end of file diff --git a/src/v2/components/select/README.md b/src/v2/components/select/README.md new file mode 100644 index 000000000..41c31d1a1 --- /dev/null +++ b/src/v2/components/select/README.md @@ -0,0 +1,202 @@ +# solid-ui-select component + +A Lit-based custom element that renders a styled select control with a custom popup listbox. It supports keyboard navigation, emits a `change` event when the selected value changes, and keeps the currently selected option at the top of the popup when opened. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```javascript +import { Select } from 'solid-ui/components/select' +``` + +```html + + + +``` + +## Usage in a plain HTML page (CDN / script tag) + +```html + + + + + +``` + +## TypeScript + +```typescript +import { Select } from 'solid-ui/components/select' + +const select = document.querySelector('solid-ui-select') as Select +select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr', disabled: false } +] + +select.addEventListener('change', (e: CustomEvent<{ value: string }>) => { + console.log(e.detail.value) +}) +``` + +`options` expects an array of: + +```typescript +type SelectOption = { + label: string + value: string + disabled?: boolean +} +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|----------|-----------|------|---------|-------------| +| `label` | `label` | `string` | `Select an option` | Fallback label shown when there is no selected value and no options are available. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. | +| `options` | `options` | `SelectOption[]` | `[]` | Array of selectable options. In practice this should be set as a property from JavaScript rather than as an HTML attribute. | +| `layout` | `layout` | `'desktop' \| 'mobile'` | `'desktop'` | Layout mode reserved for integration with other responsive components. | +| `value` | `value` | `string` | `''` | The currently selected option value. If it matches an option, that option is shown in the trigger and moved to the top of the popup when opened. | + +### Events + +| Event | Detail | Description | +|-------|--------|-------------| +| `change` | `{ value: string }` | Fired when the user selects an option from the popup or confirms a keyboard selection. | + +### CSS custom properties + +These can be set on `solid-ui-select`, on a container element, or on `:root`. + +| Variable | Fallback | Description | +|----------|----------|-------------| +| `--select-z-index` | `400` / `900` in dark theme | Base host stacking level before the popup opens. | +| `--select-open-z-index` | `1000` | Host stacking level while the popup is open. | +| `--select-popup-z-index` | `1001` | Popup stacking level inside the open host. | +| `--select-popup-extra-width` | `2px` | Extra popup width beyond the trigger width. | +| `--select-popup-width` | `100%` | Base popup width before extra width is applied. | +| `--select-popup-background` | `--color-background` | Popup surface background. | +| `--select-trigger-background` | `--color-background` | Trigger background. | +| `--select-trigger-border` | `1px solid var(--gray-400, #99A1AF)` | Trigger border. | +| `--select-trigger-text` | `--color-text-subheading` | Trigger text colour. | +| `--select-trigger-height` | `--min-touch-target` / `44px` | Height of the trigger and option rows. | +| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour. | +| `--popup-text` | `--color-text` | Popup text colour. | +| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px ...` | Popup shadow. | +| `--input-background` | `--color-background` | Listbox and option row background. | +| `--item-text` | `--color-text` | Option text colour. | +| `--item-selected-text` | `--color-primary` / `#7c4dff` | Active option text colour. | +| `--item-hover-background` | `--lavender-300` / `#e6dcff` | Hover background for option rows. | +| `--item-selected-background` | `--lavender-400` / `#cbb9ff` | Active option background. | + +The component also inherits common design-system tokens such as `--border-radius-base`, `--border-radius-sm`, `--spacing-xxs`, `--spacing-xs`, `--font-size-sm`, `--font-weight-md`, `--font-weight-bold`, `--gray-400`, `--color-background`, `--color-text`, `--color-text-subheading`, `--color-border`, `--color-primary`, `--lavender-300`, `--lavender-400`, `--box-shadow-sm`, and `--min-touch-target`. + +### CSS shadow parts + +These parts can be styled from a consuming repo using `::part(...)`. + +| Part | Description | +|------|-------------| +| `select-trigger` | The trigger button. | +| `trigger-label` | The text label inside the trigger. | +| `trigger-icon` | The down-arrow icon wrapper inside the trigger. | +| `popup-box` | The popup container that wraps the listbox. | +| `listbox` | The `
    ` element that contains the options. | +| `option` | Every option row. | +| `selected-option` | Added to the currently selected option row. | +| `active-option` | Added to the currently keyboard-active option row. | +| `disabled-option` | Added to disabled option rows. | + +## Theming + +Set `theme="dark"` when placing the select on a dark background. + +```html + +``` + +In dark theme, the component switches its background and text fallbacks to dark-surface values while keeping the same public styling hooks. + +## Popup behaviour + +- Opens a popup listbox directly under the trigger. +- Keeps the currently selected option at the top of the popup when opened. +- Supports keyboard navigation with `ArrowUp`, `ArrowDown`, `Home`, `End`, `Enter`, `Space`, and `Escape`. +- Emits a `change` event with the selected value when the user picks an option. +- Closes when clicking outside the component. +- Skips disabled options during selection and keyboard navigation. + +## Styling from a consuming repo + +Use CSS custom properties on the host element for most theming: + +```css +solid-ui-select { + width: 100%; + --select-trigger-height: 48px; + --select-trigger-background: #ffffff; + --select-trigger-border: 1px solid #c7ced8; + --select-trigger-text: #101828; + --select-popup-background: #ffffff; + --select-popup-extra-width: 0px; + --item-hover-background: #eee7ff; + --item-selected-background: #d9c8ff; + --border-radius-sm: 0.2rem; +} +``` + +Use `::part(...)` when you need to target exposed internal elements directly: + +```css +solid-ui-select::part(select-trigger) { + box-shadow: none; +} + +solid-ui-select::part(popup-box) { + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); +} + +solid-ui-select::part(option) { + letter-spacing: 0.01em; +} + +solid-ui-select::part(selected-option) { + font-weight: 700; +} +``` + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/select/index.*`. diff --git a/src/v2/components/select/Select.test.ts b/src/v2/components/select/Select.test.ts new file mode 100644 index 000000000..f58f35bc2 --- /dev/null +++ b/src/v2/components/select/Select.test.ts @@ -0,0 +1,150 @@ +import { Select } from './Select' +import './index' + +describe('SolidUISelect', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-select')).toBe(Select) + }) + + it('renders the trigger with the first option label by default', async () => { + const select = new Select() + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + const triggerIcon = select.shadowRoot?.querySelector('.select-trigger-icon svg') as SVGElement + + expect(trigger).not.toBeNull() + expect(triggerIcon).not.toBeNull() + expect(trigger.getAttribute('aria-haspopup')).toBe('listbox') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(trigger.textContent).toContain('English') + }) + + it('opens the popup and updates the value when an option is clicked', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const listbox = select.shadowRoot?.querySelector('[role="listbox"]') as HTMLElement + const options = select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf + + expect(listbox).not.toBeNull() + expect(options).toHaveLength(2) + + options[1].click() + await select.updateComplete + + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('renders the selected option first in the popup', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + select.value = 'fr' + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const options = Array.from(select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + it('supports keyboard selection from the trigger', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-activedescendant')).toBeTruthy() + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await select.updateComplete + + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('closes the popup when clicking outside the component', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/src/v2/components/select/Select.ts b/src/v2/components/select/Select.ts new file mode 100644 index 000000000..51c1a55f7 --- /dev/null +++ b/src/v2/components/select/Select.ts @@ -0,0 +1,376 @@ +import { css, html, LitElement } from "lit" +import { phoneIcon as downArrowIcon } from '../loginButton/downArrow' +import { renderListbox } from '../selectShared/listboxTemplate' +import { SelectOption } from "../selectShared/optionTypes" +import { listboxStyles } from '../selectShared/listboxStyles' +import { findOptionIndexByValue, getFirstEnabledIndex, getLastEnabledIndex, getListboxActionFromKey, getNextEnabledIndex } from '../selectShared/keyboard' + +/* The following keyboard navigation and ARIA support for Select + were generated by AI Model: GPT-5.4 */ +/* Prompt: can you wire up the keyboard interactions and aria attributes for Select */ +export class Select extends LitElement { + private static _nextId = 0 + private readonly _handleDocumentPointerDown = (event: Event) => { + const eventTarget = event.target + + if (!this._popupOpen || !(eventTarget instanceof Node)) { + return + } + + const eventPath = 'composedPath' in event ? (event as Event & { composedPath: () => EventTarget[] }).composedPath() : [] + + if (eventPath.includes(this)) { + return + } + + if (!this.contains(eventTarget)) { + this._closePopup() + } + } + + static properties = { + label: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + options: { type: Array, reflect: true }, + layout: { type: String, reflect: true }, + value: { type: String, reflect: true }, + _popupOpen: { state: true }, + _activeIndex: { state: true } + } + + static styles = [listboxStyles, css` + :host { // default theme + display: inline-block; + position: relative; + z-index: var(--select-z-index, 400); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #F8F9FB); + --select-trigger-background: var(--color-background, #F8F9FB); + --select-trigger-border: 1px solid var(--gray-400, #99A1AF); + --select-trigger-text: var(--color-text-subheading, #101828); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --popup-text: var(--color-text, #1A1A1A); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: var(--select-z-index, 900); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #1A1A1A); + --select-trigger-background: var(--color-background, #1A1A1A); + --select-trigger-border: 1px solid var(--gray-400, #99A1AF); + --select-trigger-text: var(--color-text-subheading, #F8F9FB); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --popup-text: var(--color-text, #F8F9FB); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + } + + :host([popup-open]) { + z-index: var(--select-open-z-index); + } + + .select-trigger { + display: flex; + width: 100%; + min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + height: var(--select-trigger-height, var(--min-touch-target, 44px)); + padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); + align-items: center; + justify-content: space-between; + gap: var(--spacing-xxs, 0.3125rem); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--select-trigger-background); + border: var(--select-trigger-border, 1px solid var(--gray-400, #99A1AF)); + color: var(--select-trigger-text); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-md, 500); + line-height: normal; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: transform 0.2s ease; + } + + .select-trigger:active { + transform: translateY(1px); + } + + .select-trigger-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-trigger-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 0.75rem; + height: 0.4375rem; + pointer-events: none; + } + + .select-trigger-icon svg { + width: 100%; + height: 100%; + display: block; + } + + .select-options-section { + position: relative; + background: var(--popup-background); + border-radius: inherit; + isolation: isolate; + } + + .popup-box { + position: absolute; + top: calc(100% - 1px); + left: calc(var(--select-popup-extra-width) / -2); + width: calc(var(--select-popup-width) + var(--select-popup-extra-width)); + min-width: calc(var(--select-popup-width) + var(--select-popup-extra-width)); + background: var(--popup-background); + opacity: 1; + overflow: hidden; + color: var(--popup-text); + box-shadow: var(--popup-shadow); + border: 1px solid var(--popup-border); + border-radius: var(--border-radius-sm, 0.2rem); + box-sizing: border-box; + isolation: isolate; + z-index: var(--select-popup-z-index); + } + `] + + declare label: string + declare theme: 'light' | 'dark' + declare options: Array + declare layout: 'desktop' | 'mobile' + declare value: string + declare _popupOpen: boolean + declare _activeIndex: number + + private readonly _listboxId = `solid-ui-select-listbox-${Select._nextId++}` + + constructor () { + super() + this.label = 'Select an option' + this.theme = 'light' + this.layout = 'desktop' + this.value = '' + this._popupOpen = false + this._activeIndex = -1 + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('pointerdown', this._handleDocumentPointerDown) + } + + disconnectedCallback () { + document.removeEventListener('pointerdown', this._handleDocumentPointerDown) + super.disconnectedCallback() + } + + protected updated () { + this.toggleAttribute('popup-open', this._popupOpen) + } + + private _closePopup () { + this._popupOpen = false + this._activeIndex = -1 + } + + private _getSelectedIndex () { + return findOptionIndexByValue(this.options, this.value) + } + + private _getSelectedOption () { + const selectedIndex = this._getSelectedIndex() + + if (selectedIndex >= 0) { + return this.options[selectedIndex] + } + + return this.options[0] + } + + private _getPopupOptions () { + const selectedOption = this._getSelectedOption() + + if (!selectedOption) { + return this.options + } + + return [ + selectedOption, + ...this.options.filter(option => option.value !== selectedOption.value) + ] + } + + private _getActiveOption () { + const popupOptions = this._getPopupOptions() + + if (this._activeIndex < 0) { + return undefined + } + + return popupOptions[this._activeIndex] + } + + private _selectValueFromDropdown (uri: string) { + this.value = uri + this.dispatchEvent(new CustomEvent('change', { + detail: { value: uri }, + bubbles: true, + composed: true + })) + this._closePopup() + } + + private _selectActiveOption () { + const activeOption = this._getActiveOption() + + if (activeOption && !activeOption.disabled) { + this._selectValueFromDropdown(activeOption.value) + } + } + + private _openPopup () { + const popupOptions = this._getPopupOptions() + + this._popupOpen = true + this._activeIndex = findOptionIndexByValue(popupOptions, this.value) + + if (this._activeIndex < 0) { + this._activeIndex = getFirstEnabledIndex(popupOptions) + } + } + + private _handleTriggerKeydown (event: KeyboardEvent) { + const popupOptions = this._getPopupOptions() + const action = getListboxActionFromKey(event.key) + + if (action === 'none') { + return + } + + event.preventDefault() + + switch (action) { + case 'close': + this._closePopup() + break + case 'first': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getFirstEnabledIndex(popupOptions) + break + case 'last': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getLastEnabledIndex(popupOptions) + break + case 'next': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, 1) + break + case 'previous': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, -1) + break + case 'select': + if (!this._popupOpen) { + this._openPopup() + break + } + this._selectActiveOption() + break + default: + break + } + } + + private _getOptionId (option: SelectOption, index: number) { + return `${this._listboxId}-option-${index}-${option.value}` + } + + private _renderPopup () { + const popupOptions = this._getPopupOptions() + const selectedOption = this._getSelectedOption() + const activeOption = this._getActiveOption() + + return html` + + ` + } + + render () { + const selectedOption = this._getSelectedOption() + const activeOption = this._getActiveOption() + const triggerLabel = selectedOption?.label ?? this.label + const activeDescendant = this._popupOpen && activeOption + ? this._getOptionId(activeOption, this._activeIndex) + : undefined + + return html` + + +
    + ${this._popupOpen ? this._renderPopup() : ''} +
    + ` + } +} diff --git a/src/v2/components/select/index.ts b/src/v2/components/select/index.ts new file mode 100644 index 000000000..da95d70e1 --- /dev/null +++ b/src/v2/components/select/index.ts @@ -0,0 +1,9 @@ +import { Select } from "./Select" + +export { Select } + +const SELECT_TAG_NAME = 'solid-ui-select' + +if (!customElements.get(SELECT_TAG_NAME)) { + customElements.define(SELECT_TAG_NAME, Select) +} diff --git a/src/v2/components/selectShared/keyboard.ts b/src/v2/components/selectShared/keyboard.ts index e69de29bb..c74ae0cf4 100644 --- a/src/v2/components/selectShared/keyboard.ts +++ b/src/v2/components/selectShared/keyboard.ts @@ -0,0 +1,82 @@ +import { SelectOption } from "./optionTypes" + +/* Move up or down, skip disabled options */ +export function getNextEnabledIndex( + currentIndex: number, + options: SelectOption[], + direction: 1 | -1 +): number { + if (!options.length) { + return -1 + } + + if (options.every(option => option.disabled)) { + return -1 + } + + const optionsCount = options.length + let nextIndex = currentIndex + + do { + nextIndex = (nextIndex + direction + optionsCount) % optionsCount + } while (options[nextIndex].disabled) + + return nextIndex +} + +/* Handle 'Home' and 'End' keys and initial highlight */ +export function getFirstEnabledIndex(options: SelectOption[]): number { + if (!options.length) { + return -1 + } + + return getNextEnabledIndex(-1, options, 1) +} + +export function getLastEnabledIndex(options: SelectOption[]): number { + if (!options.length) { + return -1 + } + + return getNextEnabledIndex(options.length, options, -1) +} + +/* Sync current value to active index */ +export function findOptionIndexByValue( + options: SelectOption[], + value?: string +): number { + if (value === undefined) { + return -1 + } + return options.findIndex(option => option.value === value) +} + +/* Map keyboard events to actions */ +export function getListboxActionFromKey(key: string): + | 'open' + | 'close' + | 'next' + | 'previous' + | 'first' + | 'last' + | 'select' + | 'none' { + switch (key) { + case 'ArrowDown': + return 'next' + case 'ArrowUp': + return 'previous' + case 'Home': + return 'first' + case 'End': + return 'last' + case 'Enter': + case ' ': + return 'select' + case 'Escape': + return 'close' + default: + return 'none' + } +} diff --git a/src/v2/components/selectShared/listboxStyles.ts b/src/v2/components/selectShared/listboxStyles.ts index ea342eaf1..7ed20bd72 100644 --- a/src/v2/components/selectShared/listboxStyles.ts +++ b/src/v2/components/selectShared/listboxStyles.ts @@ -2,47 +2,54 @@ import { css } from 'lit' export const listboxStyles = css` :host { // default theme - display: inline-block; - position: relative; - z-index: 200; --input-background: var(--color-background, #F8F9FB); --item-text: var(--color-text, #1A1A1A); - --item-hover-background: var(--lavender-900, #7c4cff); + --item-selected-text: var(--color-primary, #7c4dff); + --item-hover-background: var(--lavender-300, #e6dcff); + --item-selected-background: var(--lavender-400, #cbb9ff); + --listbox-z-index: 1; } :host([theme='dark']) { - display: inline-block; - position: relative; - z-index: 200; - --input-background: var(--color-background, #F8F9FB); - --item-text: var(--color-text, #1A1A1A); - --item-hover-background: var(--lavender-900, #7c4cff); + --input-background: var(--color-background, #1A1A1A); + --item-text: var(--color-text, #F8F9FB); + --item-selected-text: var(--color-primary, #7c4dff); + --item-hover-background: var(--lavender-300, #e6dcff); + --item-selected-background: var(--lavender-400, #cbb9ff); + --listbox-z-index: 1; } .listbox { - position: absolute; - top: calc(100% + 6px); + position: relative; + top: 0; left: 0; right: 0; - border: 1px solid var(--color-border, #E5E7EB); - border-top: none; - border-radius: 0 0 var(--border-radius-base, 0.3125rem) var(--border-radius-base, 0.3125rem); + margin: 0; + padding: 0; + list-style: none; + border: none; + border-radius: inherit; background: var(--input-background); - overflow: visible; - z-index: 10; + background-color: var(--input-background); + opacity: 1; + overflow: hidden; + z-index: var(--listbox-z-index); box-shadow: 0 4px 12px rgba(124, 77, 255, 0.12); } .listbox-item { - display: block; + display: flex; + align-items: center; width: 100%; - padding: 0.625rem 0.75rem; + min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); border: none; border-bottom: 1px solid var(--color-border, #E5E7EB); - background: transparent; + background: var(--input-background); color: var(--item-text); cursor: pointer; font: inherit; + line-height: normal; text-align: left; box-sizing: border-box; } @@ -53,12 +60,13 @@ export const listboxStyles = css` .listbox-item:hover { background: var(--item-hover-background); - border-radius: var(--border-radius-base-md, 0.5rem); + border-radius: var(--border-radius-sm, 0.2rem); } .listbox-item-active { - background: var(--item-hover-background); - border-radius: var(--border-radius-base-md, 0.5rem); + background: var(--item-selected-background); + color: var(--item-selected-text); + border-radius: var(--border-radius-sm, 0.2rem); outline: none; } diff --git a/src/v2/components/selectShared/listboxTemplate.ts b/src/v2/components/selectShared/listboxTemplate.ts index b441d14c3..6c465c177 100644 --- a/src/v2/components/selectShared/listboxTemplate.ts +++ b/src/v2/components/selectShared/listboxTemplate.ts @@ -24,6 +24,7 @@ export function renderListbox(args: RenderListboxArgs) {
      @@ -36,6 +37,7 @@ export function renderListbox(args: RenderListboxArgs) {
    • Date: Wed, 29 Apr 2026 14:08:15 +1000 Subject: [PATCH 11/36] export select webcomponent --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 1550fc74c..f16d5dc40 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "types": "./dist/components/footer/index.d.ts", "import": "./dist/components/footer/index.esm.js", "require": "./dist/components/footer/index.js" + }, + "./components/select": { + "types": "./dist/components/select/index.d.ts", + "import": "./dist/components/select/index.esm.js", + "require": "./dist/components/select/index.js" } }, "files": [ From 41845b75316c43f07d0d82b5a5cfaf74fa0201fb Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 17:20:41 +1000 Subject: [PATCH 12/36] minor adj to select --- src/v2/components/select/Select.ts | 361 +++++++++++++++----------- src/v2/components/select/downArrow.ts | 10 + 2 files changed, 215 insertions(+), 156 deletions(-) create mode 100644 src/v2/components/select/downArrow.ts diff --git a/src/v2/components/select/Select.ts b/src/v2/components/select/Select.ts index 51c1a55f7..da9105f62 100644 --- a/src/v2/components/select/Select.ts +++ b/src/v2/components/select/Select.ts @@ -1,9 +1,15 @@ -import { css, html, LitElement } from "lit" -import { phoneIcon as downArrowIcon } from '../loginButton/downArrow' +import { css, html, LitElement } from 'lit' +import { downArrowIcon } from './downArrow' import { renderListbox } from '../selectShared/listboxTemplate' -import { SelectOption } from "../selectShared/optionTypes" +import { SelectOption } from '../selectShared/optionTypes' import { listboxStyles } from '../selectShared/listboxStyles' -import { findOptionIndexByValue, getFirstEnabledIndex, getLastEnabledIndex, getListboxActionFromKey, getNextEnabledIndex } from '../selectShared/keyboard' +import { + findOptionIndexByValue, + getFirstEnabledIndex, + getLastEnabledIndex, + getListboxActionFromKey, + getNextEnabledIndex +} from '../selectShared/keyboard' /* The following keyboard navigation and ARIA support for Select were generated by AI Model: GPT-5.4 */ @@ -17,7 +23,12 @@ export class Select extends LitElement { return } - const eventPath = 'composedPath' in event ? (event as Event & { composedPath: () => EventTarget[] }).composedPath() : [] + const eventPath = + 'composedPath' in event + ? ( + event as Event & { composedPath: () => EventTarget[] } + ).composedPath() + : [] if (eventPath.includes(this)) { return @@ -38,125 +49,145 @@ export class Select extends LitElement { _activeIndex: { state: true } } - static styles = [listboxStyles, css` - :host { // default theme - display: inline-block; - position: relative; - z-index: var(--select-z-index, 400); - box-sizing: border-box; - --select-open-z-index: 1000; - --select-popup-z-index: 1001; - --select-popup-extra-width: 2px; - --select-popup-background: var(--color-background, #F8F9FB); - --select-trigger-background: var(--color-background, #F8F9FB); - --select-trigger-border: 1px solid var(--gray-400, #99A1AF); - --select-trigger-text: var(--color-text-subheading, #101828); - --select-popup-width: 100%; - --popup-background: var(--select-popup-background); - --popup-text: var(--color-text, #1A1A1A); - --popup-border: var(--color-border, #E5E7EB); - --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); - } - - :host([theme='dark']) { - display: inline-block; - position: relative; - z-index: var(--select-z-index, 900); - box-sizing: border-box; - --select-open-z-index: 1000; - --select-popup-z-index: 1001; - --select-popup-extra-width: 2px; - --select-popup-background: var(--color-background, #1A1A1A); - --select-trigger-background: var(--color-background, #1A1A1A); - --select-trigger-border: 1px solid var(--gray-400, #99A1AF); - --select-trigger-text: var(--color-text-subheading, #F8F9FB); - --select-popup-width: 100%; - --popup-background: var(--select-popup-background); - --popup-text: var(--color-text, #F8F9FB); - --popup-border: var(--color-border, #E5E7EB); - --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); - } - - :host([popup-open]) { - z-index: var(--select-open-z-index); - } - - .select-trigger { - display: flex; - width: 100%; - min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); - height: var(--select-trigger-height, var(--min-touch-target, 44px)); - padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); - align-items: center; - justify-content: space-between; - gap: var(--spacing-xxs, 0.3125rem); - border-radius: var(--border-radius-base, 0.3125rem); - background: var(--select-trigger-background); - border: var(--select-trigger-border, 1px solid var(--gray-400, #99A1AF)); - color: var(--select-trigger-text); - cursor: pointer; - font-family: inherit; - font-size: var(--font-size-sm, 0.875rem); - font-weight: var(--font-weight-md, 500); - line-height: normal; - white-space: nowrap; - text-decoration: none; - box-sizing: border-box; - transition: transform 0.2s ease; - } - - .select-trigger:active { - transform: translateY(1px); - } - - .select-trigger-label { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - } - - .select-trigger-icon { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - width: 0.75rem; - height: 0.4375rem; - pointer-events: none; - } - - .select-trigger-icon svg { - width: 100%; - height: 100%; - display: block; - } - - .select-options-section { - position: relative; - background: var(--popup-background); - border-radius: inherit; - isolation: isolate; - } - - .popup-box { - position: absolute; - top: calc(100% - 1px); - left: calc(var(--select-popup-extra-width) / -2); - width: calc(var(--select-popup-width) + var(--select-popup-extra-width)); - min-width: calc(var(--select-popup-width) + var(--select-popup-extra-width)); - background: var(--popup-background); - opacity: 1; - overflow: hidden; - color: var(--popup-text); - box-shadow: var(--popup-shadow); - border: 1px solid var(--popup-border); - border-radius: var(--border-radius-sm, 0.2rem); - box-sizing: border-box; - isolation: isolate; - z-index: var(--select-popup-z-index); - } - `] + static styles = [ + listboxStyles, + css` + :host { + // default theme + display: inline-block; + position: relative; + z-index: var(--select-z-index, 400); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #f8f9fb); + --select-trigger-background: var(--color-background, #f8f9fb); + --select-trigger-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-text: var(--color-text-subheading, #101828); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --popup-text: var(--color-text, #1a1a1a); + --popup-border: var(--color-border, #e5e7eb); + --popup-shadow: var( + --box-shadow-sm, + 0 1px 4px rgba(124, 77, 255, 0.12) + ); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: var(--select-z-index, 900); + box-sizing: border-box; + --select-open-z-index: 1000; + --select-popup-z-index: 1001; + --select-popup-extra-width: 2px; + --select-popup-background: var(--color-background, #1a1a1a); + --select-trigger-background: var(--color-background, #1a1a1a); + --select-trigger-border: 1px solid var(--gray-400, #99a1af); + --select-trigger-text: var(--color-text-subheading, #f8f9fb); + --select-popup-width: 100%; + --popup-background: var(--select-popup-background); + --popup-text: var(--color-text, #f8f9fb); + --popup-border: var(--color-border, #e5e7eb); + --popup-shadow: var( + --box-shadow-sm, + 0 1px 4px rgba(124, 77, 255, 0.12) + ); + } + + :host([popup-open]) { + z-index: var(--select-open-z-index); + } + + .select-trigger { + display: flex; + width: 100%; + min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); + height: var(--select-trigger-height, var(--min-touch-target, 44px)); + padding: var(--spacing-xxs, 0.3125rem) + var(--select-trigger-inline-padding, var(--spacing-2xs, 0.625rem)); + align-items: center; + justify-content: space-between; + gap: var(--spacing-xxs, 0.3125rem); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--select-trigger-background); + border: var( + --select-trigger-border, + 1px solid var(--gray-400, #99a1af) + ); + color: var(--select-trigger-text); + cursor: pointer; + font-family: inherit; + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-md, 500); + line-height: normal; + text-align: left; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: transform 0.2s ease; + } + + .select-trigger:active { + transform: translateY(1px); + } + + .select-trigger-label { + flex: 1 1 auto; + min-width: 0; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-trigger-icon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 0.75rem; + height: 0.4375rem; + pointer-events: none; + } + + .select-trigger-icon svg { + width: 100%; + height: 100%; + display: block; + } + + .select-options-section { + position: relative; + background: var(--popup-background); + border-radius: inherit; + isolation: isolate; + } + + .popup-box { + position: absolute; + top: calc(100% - 1px); + left: calc(var(--select-popup-extra-width) / -2); + width: calc( + var(--select-popup-width) + var(--select-popup-extra-width) + ); + min-width: calc( + var(--select-popup-width) + var(--select-popup-extra-width) + ); + background: var(--popup-background); + opacity: 1; + overflow: hidden; + color: var(--popup-text); + box-shadow: var(--popup-shadow); + border: 1px solid var(--popup-border); + border-radius: var(--border-radius-sm, 0.2rem); + box-sizing: border-box; + isolation: isolate; + z-index: var(--select-popup-z-index); + } + ` + ] declare label: string declare theme: 'light' | 'dark' @@ -167,8 +198,8 @@ export class Select extends LitElement { declare _activeIndex: number private readonly _listboxId = `solid-ui-select-listbox-${Select._nextId++}` - - constructor () { + + constructor() { super() this.label = 'Select an option' this.theme = 'light' @@ -178,30 +209,30 @@ export class Select extends LitElement { this._activeIndex = -1 } - connectedCallback () { + connectedCallback() { super.connectedCallback() document.addEventListener('pointerdown', this._handleDocumentPointerDown) } - disconnectedCallback () { + disconnectedCallback() { document.removeEventListener('pointerdown', this._handleDocumentPointerDown) super.disconnectedCallback() } - protected updated () { + protected updated() { this.toggleAttribute('popup-open', this._popupOpen) } - private _closePopup () { + private _closePopup() { this._popupOpen = false this._activeIndex = -1 } - private _getSelectedIndex () { + private _getSelectedIndex() { return findOptionIndexByValue(this.options, this.value) } - private _getSelectedOption () { + private _getSelectedOption() { const selectedIndex = this._getSelectedIndex() if (selectedIndex >= 0) { @@ -211,7 +242,7 @@ export class Select extends LitElement { return this.options[0] } - private _getPopupOptions () { + private _getPopupOptions() { const selectedOption = this._getSelectedOption() if (!selectedOption) { @@ -220,11 +251,11 @@ export class Select extends LitElement { return [ selectedOption, - ...this.options.filter(option => option.value !== selectedOption.value) + ...this.options.filter((option) => option.value !== selectedOption.value) ] } - private _getActiveOption () { + private _getActiveOption() { const popupOptions = this._getPopupOptions() if (this._activeIndex < 0) { @@ -234,17 +265,19 @@ export class Select extends LitElement { return popupOptions[this._activeIndex] } - private _selectValueFromDropdown (uri: string) { + private _selectValueFromDropdown(uri: string) { this.value = uri - this.dispatchEvent(new CustomEvent('change', { - detail: { value: uri }, - bubbles: true, - composed: true - })) + this.dispatchEvent( + new CustomEvent('change', { + detail: { value: uri }, + bubbles: true, + composed: true + }) + ) this._closePopup() } - private _selectActiveOption () { + private _selectActiveOption() { const activeOption = this._getActiveOption() if (activeOption && !activeOption.disabled) { @@ -252,7 +285,7 @@ export class Select extends LitElement { } } - private _openPopup () { + private _openPopup() { const popupOptions = this._getPopupOptions() this._popupOpen = true @@ -263,7 +296,7 @@ export class Select extends LitElement { } } - private _handleTriggerKeydown (event: KeyboardEvent) { + private _handleTriggerKeydown(event: KeyboardEvent) { const popupOptions = this._getPopupOptions() const action = getListboxActionFromKey(event.key) @@ -294,14 +327,22 @@ export class Select extends LitElement { this._openPopup() break } - this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, 1) + this._activeIndex = getNextEnabledIndex( + this._activeIndex, + popupOptions, + 1 + ) break case 'previous': if (!this._popupOpen) { this._openPopup() break } - this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, -1) + this._activeIndex = getNextEnabledIndex( + this._activeIndex, + popupOptions, + -1 + ) break case 'select': if (!this._popupOpen) { @@ -315,11 +356,11 @@ export class Select extends LitElement { } } - private _getOptionId (option: SelectOption, index: number) { + private _getOptionId(option: SelectOption, index: number) { return `${this._listboxId}-option-${index}-${option.value}` } - private _renderPopup () { + private _renderPopup() { const popupOptions = this._getPopupOptions() const selectedOption = this._getSelectedOption() const activeOption = this._getActiveOption() @@ -333,20 +374,22 @@ export class Select extends LitElement { options: popupOptions, listboxId: this._listboxId, getOptionId: (option, index) => this._getOptionId(option, index), - onOptionSelect: (option) => this._selectValueFromDropdown(option.value) + onOptionSelect: (option) => + this._selectValueFromDropdown(option.value) })} ` } - render () { + render() { const selectedOption = this._getSelectedOption() const activeOption = this._getActiveOption() const triggerLabel = selectedOption?.label ?? this.label - const activeDescendant = this._popupOpen && activeOption - ? this._getOptionId(activeOption, this._activeIndex) - : undefined + const activeDescendant = + this._popupOpen && activeOption + ? this._getOptionId(activeOption, this._activeIndex) + : undefined return html`
      ${this._popupOpen ? this._renderPopup() : ''}
      diff --git a/src/v2/components/select/downArrow.ts b/src/v2/components/select/downArrow.ts new file mode 100644 index 000000000..57960daf1 --- /dev/null +++ b/src/v2/components/select/downArrow.ts @@ -0,0 +1,10 @@ +import { html } from 'lit-html' + +export const downArrowIcon = html` + + + +` \ No newline at end of file From 2367666f721c99b5b8bb6285a8e847914eb1b816 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 17:23:04 +1000 Subject: [PATCH 13/36] fix typescript errors test file --- src/v2/components/select/Select.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/v2/components/select/Select.test.ts b/src/v2/components/select/Select.test.ts index f58f35bc2..65bdb2f7b 100644 --- a/src/v2/components/select/Select.test.ts +++ b/src/v2/components/select/Select.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { Select } from './Select' import './index' From 24aceeecf6f8a2e56221aca2d06b44080c909319 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 17:27:33 +1000 Subject: [PATCH 14/36] move arrowicon file --- src/v2/components/select/Select.ts | 2 +- src/v2/components/{select => selectShared}/downArrow.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/v2/components/{select => selectShared}/downArrow.ts (100%) diff --git a/src/v2/components/select/Select.ts b/src/v2/components/select/Select.ts index da9105f62..1283c98dc 100644 --- a/src/v2/components/select/Select.ts +++ b/src/v2/components/select/Select.ts @@ -1,5 +1,5 @@ import { css, html, LitElement } from 'lit' -import { downArrowIcon } from './downArrow' +import { downArrowIcon } from '../selectShared/downArrow' import { renderListbox } from '../selectShared/listboxTemplate' import { SelectOption } from '../selectShared/optionTypes' import { listboxStyles } from '../selectShared/listboxStyles' diff --git a/src/v2/components/select/downArrow.ts b/src/v2/components/selectShared/downArrow.ts similarity index 100% rename from src/v2/components/select/downArrow.ts rename to src/v2/components/selectShared/downArrow.ts From b67db1cf9b0ab8d737520400ba66609d9c89ad57 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 21:31:45 +1000 Subject: [PATCH 15/36] combobox web component --- package.json | 5 + src/v2/components/combobox/Combobox.test.ts | 0 src/v2/components/combobox/Combobox.ts | 550 ++++++++++++++++++++ src/v2/components/combobox/README.md | 221 ++++++++ src/v2/components/combobox/comboboxTypes.ts | 6 + src/v2/components/combobox/index.ts | 9 + src/v2/components/select/Select.ts | 10 +- webpack.config.mjs | 3 + 8 files changed, 799 insertions(+), 5 deletions(-) create mode 100644 src/v2/components/combobox/Combobox.test.ts create mode 100644 src/v2/components/combobox/Combobox.ts create mode 100644 src/v2/components/combobox/README.md create mode 100644 src/v2/components/combobox/comboboxTypes.ts create mode 100644 src/v2/components/combobox/index.ts diff --git a/package.json b/package.json index f16d5dc40..2f3b3a46a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "types": "./dist/components/select/index.d.ts", "import": "./dist/components/select/index.esm.js", "require": "./dist/components/select/index.js" + }, + "./components/combobox": { + "types": "./dist/components/combobox/index.d.ts", + "import": "./dist/components/combobox/index.esm.js", + "require": "./dist/components/combobox/index.js" } }, "files": [ diff --git a/src/v2/components/combobox/Combobox.test.ts b/src/v2/components/combobox/Combobox.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/v2/components/combobox/Combobox.ts b/src/v2/components/combobox/Combobox.ts new file mode 100644 index 000000000..d9c6600a5 --- /dev/null +++ b/src/v2/components/combobox/Combobox.ts @@ -0,0 +1,550 @@ +import { LitElement, html, css, nothing } from 'lit' +import { render as renderPortal } from 'lit/html.js' +import { downArrowIcon } from '../selectShared/downArrow' +import { listboxStyles } from '../selectShared/listboxStyles' +import { findOptionIndexByValue, getFirstEnabledIndex, getLastEnabledIndex, getListboxActionFromKey, getNextEnabledIndex } from '../selectShared/keyboard' +import { ComboboxSuggestion } from './comboboxTypes' +import { renderListbox } from '../selectShared/listboxTemplate' + +export class Combobox extends LitElement { + private static _nextId = 0 + private _popupPortalHost: HTMLDivElement | null = null + private _popupPortalRoot: ShadowRoot | null = null + private _popupPortalContainer: Element | null = null + private readonly _handleDocumentPointerDown = (event: Event) => { + const eventTarget = event.target + + if (!this._popupOpen || !(eventTarget instanceof Node)) { + return + } + + const eventPath = + 'composedPath' in event + ? (event as Event & { composedPath: () => EventTarget[] }).composedPath() + : [] + + if (eventPath.includes(this)) { + return + } + + if ( + (this._popupPortalHost && eventPath.includes(this._popupPortalHost)) || + (this._popupPortalRoot && eventPath.includes(this._popupPortalRoot)) + ) { + return + } + + if (!this.contains(eventTarget)) { + this._closePopup() + } + } + + private readonly _handleViewportChange = () => { + if (!this._popupOpen) return + this._updatePopupPosition() + } + + suggestionProvider?: (query: string) => Promise + + static properties = { + label: { type: String, reflect: true }, + placeholder: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + layout: { type: String, reflect: true }, + value: { type: String, reflect: true }, + inputValue: { type: String }, + _popupOpen: { state: true }, + _activeIndex: { state: true } + } + + static styles = [ + listboxStyles, + css` + :host { // default theme + display: inline-block; + position: relative; + z-index: var(--combobox-z-index, 400); + box-sizing: border-box; + --combobox-open-z-index: 1000; + --combobox-popup-z-index: 1001; + --popup-background: var(--color-background, #F8F9FB); + --popup-text: var(--color-text, #1A1A1A); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + --input-background: var(--color-background, #F8F9FB); + --input-text: var(--color-text, #1A1A1A); + --input-border: var(--color-text, #1A1A1A); + --label-color: var(--grey-purple-700, #1A1A1A); + --placeholder-color: var(--grey-purple-700, #5e546d); + } + + :host([theme='dark']) { + display: inline-block; + position: relative; + z-index: var(--combobox-z-index, 900); + box-sizing: border-box; + --combobox-open-z-index: 1000; + --combobox-popup-z-index: 1001; + --popup-background: var(--color-background, #F8F9FB); + --popup-text: var(--color-text, #1A1A1A); + --popup-border: var(--color-border, #E5E7EB); + --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12)); + --input-background: var(--color-background, #F8F9FB); + --input-text: var(--color-text, #1A1A1A); + --input-border: var(--color-text, #1A1A1A); + --label-color: var(--grey-purple-700, #1A1A1A); + --placeholder-color: var(--grey-purple-700, #5e546d); + } + + :host([popup-open]) { + z-index: var(--combobox-open-z-index); + } + + .popup-box { + position: absolute; + top: 0; + left: 0; + width: 100%; + background: var(--popup-background); + color: var(--popup-text); + box-shadow: var(--popup-shadow); + border: 1px solid var(--popup-border); + border-radius: var(--border-radius-md, 0.5rem); + min-width: 100%; + overflow: hidden; + box-sizing: border-box; + isolation: isolate; + z-index: var(--combobox-popup-z-index); + } + + .select-options-section { + position: relative; + background: var(--popup-background); + border-radius: inherit; + isolation: isolate; + } + + .combobox-root { + display: flex; + flex-direction: column; + gap: 6px; + } + + .text-label { + color: var(--label-color); + margin-bottom: 6px; + } + + .input-field-row { + display: flex; + flex-direction: row; + position: relative; + } + + .text-input { + flex: 1; + padding: 0.375rem 2.75rem 0.375rem 0.5rem; + border: 1px solid var(--input-border); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--input-background); + color: var(--input-text); + font: inherit; + min-width: 0; + } + + .text-input::placeholder { + color: var(--placeholder-color); + } + + .dropdown-toggle { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: var(--border-radius-base, 0.3125rem); + } + + .dropdown-toggle:hover { + background: var(--color-header-menu-item-hover, #e6dcff); + } + + .dropdown-toggle svg { + width: 14px; + height: 14px; + display: block; + } + } + ` + ] + + declare label: string + declare placeholder: string + declare theme: 'light' | 'dark' + declare options: Array + declare layout: 'desktop' | 'mobile' + declare value: string + declare inputValue: string + declare _popupOpen: boolean + declare _activeIndex: number + + private readonly _inputId = `solid-ui-combobox-input-${Combobox._nextId++}` + private readonly _listboxId = `solid-ui-combobox-listbox-${Combobox._nextId++}` + private _suggestionRequestId = 0 + + constructor () { + super() + this.label = 'Select an option' + this.placeholder = 'Type to search' + this.theme = 'light' + this.layout = 'desktop' + this.options = [] + this.value = '' + this.inputValue = '' + this._popupOpen = false + this._activeIndex = -1 + } + + connectedCallback () { + super.connectedCallback() + document.addEventListener('pointerdown', this._handleDocumentPointerDown) + window.addEventListener('resize', this._handleViewportChange) + window.addEventListener('scroll', this._handleViewportChange, true) + } + + disconnectedCallback () { + this._detachPopupPortal() + document.removeEventListener('pointerdown', this._handleDocumentPointerDown) + window.removeEventListener('resize', this._handleViewportChange) + window.removeEventListener('scroll', this._handleViewportChange, true) + super.disconnectedCallback() + } + + private _getPopupPortalContainer () { + return this.closest('dialog[open]') || document.body + } + + private _ensurePopupPortal () { + const nextContainer = this._getPopupPortalContainer() + + if ( + this._popupPortalHost && + this._popupPortalRoot && + this._popupPortalContainer === nextContainer + ) { + return + } + + this._detachPopupPortal() + + this._popupPortalHost = document.createElement('div') + this._popupPortalHost.setAttribute('data-solid-ui-combobox-portal', '') + this._popupPortalHost.style.position = 'fixed' + this._popupPortalHost.style.inset = '0 auto auto 0' + this._popupPortalHost.style.zIndex = '2147483647' + this._popupPortalHost.style.pointerEvents = 'none' + this._popupPortalHost.style.boxSizing = 'border-box' + + this._popupPortalRoot = this._popupPortalHost.attachShadow({ mode: 'open' }) + const styleSheets = (Array.isArray(Combobox.styles) ? Combobox.styles : [Combobox.styles]) + .map((style) => style?.styleSheet) + .filter((styleSheet): styleSheet is CSSStyleSheet => Boolean(styleSheet)) + + if (styleSheets.length > 0) { + this._popupPortalRoot.adoptedStyleSheets = styleSheets + } + + nextContainer.appendChild(this._popupPortalHost) + this._popupPortalContainer = nextContainer + } + + private _detachPopupPortal () { + if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + + if (this._popupPortalHost?.parentNode) { + this._popupPortalHost.parentNode.removeChild(this._popupPortalHost) + } + + this._popupPortalHost = null + this._popupPortalRoot = null + this._popupPortalContainer = null + } + + private _updatePopupPosition () { + this._ensurePopupPortal() + + const rect = this.getBoundingClientRect() + const maxHeight = Math.min(288, Math.max(120, window.innerHeight - rect.bottom - 12)) + + if (this._popupPortalHost) { + this._popupPortalHost.style.top = `${Math.round(rect.bottom + 2)}px` + this._popupPortalHost.style.left = `${Math.round(rect.left)}px` + this._popupPortalHost.style.width = `${Math.round(rect.width)}px` + this._popupPortalHost.style.maxHeight = `${Math.round(maxHeight)}px` + this._popupPortalHost.style.height = '0px' + } + } + + private _openPopup () { + const popupOptions = this._getDisplayedOptions() + + this._popupOpen = true + this._updatePopupPosition() + this._activeIndex = findOptionIndexByValue(popupOptions, this.value) + + if (this._activeIndex < 0) { + this._activeIndex = getFirstEnabledIndex(popupOptions) + } + } + + private _closePopup () { + this._popupOpen = false + if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + } + + protected updated (changedProperties: Map) { + this.toggleAttribute('popup-open', this._popupOpen) + + if (this._popupOpen) { + this._updatePopupPosition() + if (this._popupPortalRoot) { + renderPortal(this._renderPopup(), this._popupPortalRoot) + } + } else if (this._popupPortalRoot) { + renderPortal(null, this._popupPortalRoot) + } + + if ((changedProperties.has('value') || changedProperties.has('options')) && this.value) { + const selectedOption = this.options.find((option) => option.value === this.value) + if (selectedOption && this.inputValue !== selectedOption.label) { + this.inputValue = selectedOption.label + } + } + } + + private _getSelectedIndex() { + return findOptionIndexByValue(this.options, this.value) + } + + private _getSelectedOption() { + const selectedIndex = this._getSelectedIndex() + + if (selectedIndex >= 0) { + return this.options[selectedIndex] + } + + return this.options[0] + } + + private _getDisplayedOptions() { + const selectedOption = this._getSelectedOption() + + if (!selectedOption) { + return this.options + } + + return [ + selectedOption, + ...this.options.filter((option) => option.value !== selectedOption.value) + ] + } + + private _getActiveOption() { + const popupOptions = this._getDisplayedOptions() + + if (this._activeIndex < 0) { + return undefined + } + + return popupOptions[this._activeIndex] + } + + private async _loadSuggestions (query: string) { + if (!this.suggestionProvider) { + this._openPopup() + return + } + + const requestId = ++this._suggestionRequestId + const suggestions = await this.suggestionProvider(query) + + if (requestId !== this._suggestionRequestId) { + return + } + + this.options = suggestions + this._openPopup() + } + + private _handleInputChange (e: Event) { + const query = (e.target as HTMLInputElement).value + + this.inputValue = query + this.value = '' + this.dispatchEvent(new CustomEvent('input', { + detail: { value: query }, + bubbles: true, + composed: true + })) + void this._loadSuggestions(query) + } + + private _handleInputKeydown (e: KeyboardEvent) { + const popupOptions = this._getDisplayedOptions() + const action = getListboxActionFromKey(e.key) + + if (action === 'none') { + return + } + + e.preventDefault() + + switch (action) { + case 'close': + this._closePopup() + break + case 'first': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getFirstEnabledIndex(popupOptions) + break + case 'last': + if (!this._popupOpen) { + this._openPopup() + } + this._activeIndex = getLastEnabledIndex(popupOptions) + break + case 'next': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, 1) + break + case 'previous': + if (!this._popupOpen) { + this._openPopup() + break + } + this._activeIndex = getNextEnabledIndex(this._activeIndex, popupOptions, -1) + break + case 'select': + if (!this._popupOpen) { + this._openPopup() + break + } + this._selectActiveOption() + break + default: + break + } + } + + private _getOptionId (option: ComboboxSuggestion, index: number) { + return `${this._listboxId}-option-${index}-${option.value}` + } + + private _selectValueFromDropdown (value: string) { + const selectedOption = this.options.find(option => option.value === value) + + this.value = value + this.inputValue = selectedOption?.label ?? value + this.dispatchEvent(new CustomEvent('change', { + detail: { + value, + label: this.inputValue, + option: selectedOption + }, + bubbles: true, + composed: true + })) + this._closePopup() + } + + private _selectActiveOption () { + const activeOption = this._getActiveOption() + + if (activeOption && !activeOption.disabled) { + this._selectValueFromDropdown(activeOption.value) + } + } + + private _renderPopup () { + const popupOptions = this._getDisplayedOptions() + const selectedOption = this._getSelectedOption() + const activeOption = this._activeIndex >= 0 ? popupOptions[this._activeIndex] : undefined + + return html` + + ` + } + + render () { + const activeOption = this._getActiveOption() + const activeDescendant = this._popupOpen && activeOption + ? this._getOptionId(activeOption, this._activeIndex) + : undefined + const ariaLabel = this.label ? nothing : (this.getAttribute('aria-label') || this.placeholder || 'Combobox') + + return html` +
      + ${this.label + ? html`` + : null} +
      + + +
      +
      + ` + } +} diff --git a/src/v2/components/combobox/README.md b/src/v2/components/combobox/README.md new file mode 100644 index 000000000..a5b57de99 --- /dev/null +++ b/src/v2/components/combobox/README.md @@ -0,0 +1,221 @@ +# solid-ui-combobox component + +A Lit-based custom element that renders a styled combobox with a text input and a custom popup listbox. It supports async suggestion loading through a consumer-provided `suggestionProvider`, keyboard navigation, `input` and `change` events, and keeps the currently selected option at the top of the popup when opened. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```javascript +import { Combobox } from 'solid-ui/components/combobox' +``` + +```html + + + +``` + +## Usage in a plain HTML page (CDN / script tag) + +```html + + + + + +``` + +## TypeScript + +```typescript +import { Combobox } from 'solid-ui/components/combobox' + +const combobox = document.querySelector('solid-ui-combobox') as Combobox + +combobox.suggestionProvider = async (query) => { + return [ + { label: `Result for ${query}`, value: query.toLowerCase() } + ] +} + +combobox.addEventListener( + 'change', + (e: CustomEvent<{ value: string; label: string; option?: { label: string; value: string } }>) => { + console.log(e.detail.value) + } +) +``` + +The component works with suggestion objects shaped like: + +```typescript +type ComboboxSuggestion = { + label: string + value: string + disabled?: boolean + publicId?: string + meta?: Record +} +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|----------|-----------|------|---------|-------------| +| `label` | `label` | `string` | `Select an option` | Visible label rendered above the input. If omitted, provide an `aria-label` for accessibility. | +| `placeholder` | `placeholder` | `string` | `Type to search` | Placeholder text shown inside the input when it is empty. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. | +| `options` | `options` | `ComboboxSuggestion[]` | `[]` | Current list of suggestions shown in the popup. In practice this should be set as a property from JavaScript rather than as an HTML attribute. | +| `layout` | `layout` | `'desktop' \| 'mobile'` | `'desktop'` | Layout mode reserved for integration with other responsive components. | +| `value` | `value` | `string` | `''` | The currently selected suggestion value. If it matches a suggestion, that suggestion is shown in the input and moved to the top of the popup when opened. | +| `inputValue` | none | `string` | `''` | Current raw text shown in the input field. This updates as the user types. | +| `suggestionProvider` | none | `(query: string) => Promise` | `undefined` | Optional async function supplied by the consumer. It receives the current input text and returns normalized suggestions for the popup. | + +### Events + +| Event | Detail | Description | +|-------|--------|-------------| +| `input` | `{ value: string }` | Fired when the user types in the input. Useful when the consumer wants to observe free text in addition to providing a `suggestionProvider`. | +| `change` | `{ value: string; label: string; option?: ComboboxSuggestion }` | Fired when the user selects a suggestion from the popup or confirms a keyboard selection. | + +### CSS custom properties + +These can be set on `solid-ui-combobox`, on a container element, or on `:root`. + +| Variable | Fallback | Description | +|----------|----------|-------------| +| `--combobox-z-index` | `400` / `900` in dark theme | Base host stacking level before the popup opens. | +| `--combobox-open-z-index` | `1000` | Host stacking level while the popup is open. | +| `--combobox-popup-z-index` | `1001` | Popup stacking level inside the open host or portal. | +| `--popup-background` | `--color-background` | Popup surface background. | +| `--popup-text` | `--color-text` | Popup text colour. | +| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour. | +| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px ...` | Popup shadow. | +| `--input-background` | `--color-background` | Input and popup background. | +| `--input-text` | `--color-text` | Input text colour. | +| `--input-border` | `--color-text` | Input border colour. | +| `--label-color` | `--grey-purple-700` | Label text colour. | +| `--placeholder-color` | `--grey-purple-700` | Placeholder text colour. | +| `--item-text` | `--color-text` | Option text colour. | +| `--item-selected-text` | `--color-primary` / `#7c4dff` | Active option text colour. | +| `--item-hover-background` | `--lavender-300` / `#e6dcff` | Hover background for option rows. | +| `--item-selected-background` | `--lavender-400` / `#cbb9ff` | Active option background. | + +The component also inherits common design-system tokens such as `--border-radius-base`, `--border-radius-md`, `--color-background`, `--color-border`, `--color-text`, `--color-primary`, `--box-shadow-sm`, `--lavender-300`, and `--lavender-400`. + +### CSS shadow parts + +These parts can be styled from a consuming repo using `::part(...)`. + +| Part | Description | +|------|-------------| +| `listbox` | The `
        ` element that contains the suggestions. | +| `option` | Every suggestion row. | +| `selected-option` | Added to the currently selected suggestion row. | +| `active-option` | Added to the currently keyboard-active suggestion row. | +| `disabled-option` | Added to disabled suggestion rows. | + +## Theming + +Set `theme="dark"` when placing the combobox on a dark background. + +```html + +``` + +In dark theme, the component switches its background and text fallbacks to dark-surface values while keeping the same public styling hooks. + +## Popup behaviour + +- Opens a popup listbox under the combobox input. +- Keeps the currently selected option at the top of the popup when opened. +- Supports keyboard navigation with `ArrowUp`, `ArrowDown`, `Home`, `End`, `Enter`, `Space`, and `Escape`. +- Emits an `input` event as the user types and a `change` event when the user selects a suggestion. +- Closes when clicking outside the component or the popup. +- Skips disabled suggestions during selection and keyboard navigation. +- Renders the popup through a portal so it can escape clipping and stacking issues from surrounding form layouts. + +## Styling from a consuming repo + +Use CSS custom properties on the host element for most theming: + +```css +solid-ui-combobox { + width: 100%; + --input-background: #ffffff; + --input-border: #c7ced8; + --input-text: #101828; + --popup-background: #ffffff; + --popup-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); + --item-hover-background: #eee7ff; + --item-selected-background: #d9c8ff; + --border-radius-md: 0.5rem; +} +``` + +Use `::part(...)` when you need to target exposed listbox elements directly: + +```css +solid-ui-combobox::part(listbox) { + max-height: 16rem; +} + +solid-ui-combobox::part(option) { + letter-spacing: 0.01em; +} + +solid-ui-combobox::part(selected-option) { + font-weight: 700; +} +``` + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/combobox/index.*`. diff --git a/src/v2/components/combobox/comboboxTypes.ts b/src/v2/components/combobox/comboboxTypes.ts new file mode 100644 index 000000000..872f1bf64 --- /dev/null +++ b/src/v2/components/combobox/comboboxTypes.ts @@ -0,0 +1,6 @@ +import { SelectOption } from '../selectShared/optionTypes' + +export interface ComboboxSuggestion extends SelectOption { + publicId?: string + meta?: Record +} diff --git a/src/v2/components/combobox/index.ts b/src/v2/components/combobox/index.ts new file mode 100644 index 000000000..cf45328bb --- /dev/null +++ b/src/v2/components/combobox/index.ts @@ -0,0 +1,9 @@ +import { Combobox } from "./Combobox" + +export { Combobox } + +const COMBOBOX_TAG_NAME = 'solid-ui-combobox' + +if (!customElements.get(COMBOBOX_TAG_NAME)) { + customElements.define(COMBOBOX_TAG_NAME, Combobox) +} diff --git a/src/v2/components/select/Select.ts b/src/v2/components/select/Select.ts index 1283c98dc..d554628dc 100644 --- a/src/v2/components/select/Select.ts +++ b/src/v2/components/select/Select.ts @@ -242,7 +242,7 @@ export class Select extends LitElement { return this.options[0] } - private _getPopupOptions() { + private _getDisplayedOptions() { const selectedOption = this._getSelectedOption() if (!selectedOption) { @@ -256,7 +256,7 @@ export class Select extends LitElement { } private _getActiveOption() { - const popupOptions = this._getPopupOptions() + const popupOptions = this._getDisplayedOptions() if (this._activeIndex < 0) { return undefined @@ -286,7 +286,7 @@ export class Select extends LitElement { } private _openPopup() { - const popupOptions = this._getPopupOptions() + const popupOptions = this._getDisplayedOptions() this._popupOpen = true this._activeIndex = findOptionIndexByValue(popupOptions, this.value) @@ -297,7 +297,7 @@ export class Select extends LitElement { } private _handleTriggerKeydown(event: KeyboardEvent) { - const popupOptions = this._getPopupOptions() + const popupOptions = this._getDisplayedOptions() const action = getListboxActionFromKey(event.key) if (action === 'none') { @@ -361,7 +361,7 @@ export class Select extends LitElement { } private _renderPopup() { - const popupOptions = this._getPopupOptions() + const popupOptions = this._getDisplayedOptions() const selectedOption = this._getSelectedOption() const activeOption = this._getActiveOption() diff --git a/webpack.config.mjs b/webpack.config.mjs index 0d062daa5..b2b2bba0f 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -45,6 +45,9 @@ const common = { }, select: { import: './src/v2/components/select/index.ts' + }, + combobox: { + import: './src/v2/components/combobox/index.ts' } }, output: { From a1cd4d38769693f49055001fb90f3974bd8fe8fb Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 21:34:46 +1000 Subject: [PATCH 16/36] tests --- src/v2/components/combobox/Combobox.test.ts | 199 ++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/src/v2/components/combobox/Combobox.test.ts b/src/v2/components/combobox/Combobox.test.ts index e69de29bb..0b7c53f3d 100644 --- a/src/v2/components/combobox/Combobox.test.ts +++ b/src/v2/components/combobox/Combobox.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Combobox } from './Combobox' +import './index' + +function getPortalRoot() { + const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null + return portalHost?.shadowRoot ?? null +} + +async function flushUpdates() { + await Promise.resolve() + await Promise.resolve() +} + +describe('SolidUICombobox', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-combobox')).toBe(Combobox) + }) + + it('renders the input with label and placeholder', async () => { + const combobox = new Combobox() + combobox.label = 'Person' + combobox.placeholder = 'Search people' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const label = combobox.shadowRoot?.querySelector('label.text-label') as HTMLLabelElement + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + const toggle = combobox.shadowRoot?.querySelector('button.dropdown-toggle') as HTMLButtonElement + + expect(label).not.toBeNull() + expect(label.textContent).toContain('Person') + expect(input).not.toBeNull() + expect(input.placeholder).toBe('Search people') + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(toggle).not.toBeNull() + }) + + it('loads suggestions from suggestionProvider and emits input events', async () => { + const combobox = new Combobox() + const inputEvents = jest.fn() + const suggestionProvider = jest.fn(async (query: string) => [ + { label: `Alice ${query}`, value: 'alice' }, + { label: `Bob ${query}`, value: 'bob' } + ]) + + combobox.suggestionProvider = suggestionProvider + combobox.addEventListener('input', (event: Event) => { + inputEvents((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.value = 'al' + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) + + await flushUpdates() + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(suggestionProvider).toHaveBeenCalledWith('al') + expect(inputEvents).toHaveBeenCalledWith({ value: 'al' }) + expect(combobox.inputValue).toBe('al') + expect(options).toHaveLength(2) + expect(options[0].textContent).toContain('Alice al') + }) + + it('renders the selected option first in the popup', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + combobox.value = 'fr' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + + it('updates value and emits change when an option is clicked', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice', publicId: 'https://example.com/alice' }, + { label: 'Bob', value: 'bob' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf + options[1].click() + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('supports keyboard selection from the input', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' }, + { label: 'Carol', value: 'carol' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(input.getAttribute('aria-activedescendant')).toBeTruthy() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('closes the popup when clicking outside the component', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' } + ] + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(getPortalRoot()).not.toBeNull() + + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('false') + }) +}) From f2403c745e905d5a8897a59842fa84056dbbc21c Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 21:37:32 +1000 Subject: [PATCH 17/36] lint fix --- src/v2/components/combobox/Combobox.test.ts | 378 +++++++++--------- src/v2/components/combobox/Combobox.ts | 12 +- src/v2/components/combobox/index.ts | 2 +- src/v2/components/select/Select.test.ts | 278 ++++++------- src/v2/components/select/Select.ts | 32 +- src/v2/components/select/index.ts | 2 +- src/v2/components/selectShared/downArrow.ts | 2 +- src/v2/components/selectShared/keyboard.ts | 14 +- .../selectShared/listboxTemplate.ts | 4 +- src/v2/components/selectShared/optionTypes.ts | 6 +- 10 files changed, 365 insertions(+), 365 deletions(-) diff --git a/src/v2/components/combobox/Combobox.test.ts b/src/v2/components/combobox/Combobox.test.ts index 0b7c53f3d..fef98d038 100644 --- a/src/v2/components/combobox/Combobox.test.ts +++ b/src/v2/components/combobox/Combobox.test.ts @@ -2,198 +2,198 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { Combobox } from './Combobox' import './index' -function getPortalRoot() { - const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null - return portalHost?.shadowRoot ?? null +function getPortalRoot () { + const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null + return portalHost?.shadowRoot ?? null } -async function flushUpdates() { - await Promise.resolve() - await Promise.resolve() +async function flushUpdates () { + await Promise.resolve() + await Promise.resolve() } describe('SolidUICombobox', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-combobox')).toBe(Combobox) - }) - - it('renders the input with label and placeholder', async () => { - const combobox = new Combobox() - combobox.label = 'Person' - combobox.placeholder = 'Search people' - - document.body.appendChild(combobox) - await combobox.updateComplete - - const label = combobox.shadowRoot?.querySelector('label.text-label') as HTMLLabelElement - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - const toggle = combobox.shadowRoot?.querySelector('button.dropdown-toggle') as HTMLButtonElement - - expect(label).not.toBeNull() - expect(label.textContent).toContain('Person') - expect(input).not.toBeNull() - expect(input.placeholder).toBe('Search people') - expect(input.getAttribute('role')).toBe('combobox') - expect(input.getAttribute('aria-expanded')).toBe('false') - expect(toggle).not.toBeNull() - }) - - it('loads suggestions from suggestionProvider and emits input events', async () => { - const combobox = new Combobox() - const inputEvents = jest.fn() - const suggestionProvider = jest.fn(async (query: string) => [ - { label: `Alice ${query}`, value: 'alice' }, - { label: `Bob ${query}`, value: 'bob' } - ]) - - combobox.suggestionProvider = suggestionProvider - combobox.addEventListener('input', (event: Event) => { - inputEvents((event as CustomEvent).detail) - }) - - document.body.appendChild(combobox) - await combobox.updateComplete - - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - input.value = 'al' - input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) - - await flushUpdates() - await combobox.updateComplete - - const portalRoot = getPortalRoot() - const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) - - expect(suggestionProvider).toHaveBeenCalledWith('al') - expect(inputEvents).toHaveBeenCalledWith({ value: 'al' }) - expect(combobox.inputValue).toBe('al') - expect(options).toHaveLength(2) - expect(options[0].textContent).toContain('Alice al') - }) - - it('renders the selected option first in the popup', async () => { - const combobox = new Combobox() - combobox.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' }, - { label: 'Spanish', value: 'es' } - ] - combobox.value = 'fr' - - document.body.appendChild(combobox) - await combobox.updateComplete - - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - input.dispatchEvent(new Event('focus')) - await combobox.updateComplete - - const portalRoot = getPortalRoot() - const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) - - expect(options).toHaveLength(3) - expect(options[0].textContent).toContain('French') - expect(options[0].getAttribute('aria-selected')).toBe('true') - }) - - it('updates value and emits change when an option is clicked', async () => { - const combobox = new Combobox() - const changed = jest.fn() - - combobox.options = [ - { label: 'Alice', value: 'alice', publicId: 'https://example.com/alice' }, - { label: 'Bob', value: 'bob' } - ] - - combobox.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(combobox) - await combobox.updateComplete - - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - input.dispatchEvent(new Event('focus')) - await combobox.updateComplete - - const portalRoot = getPortalRoot() - const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf - options[1].click() - await combobox.updateComplete - - expect(combobox.value).toBe('bob') - expect(combobox.inputValue).toBe('Bob') - expect(input.getAttribute('aria-expanded')).toBe('false') - expect(changed).toHaveBeenCalledWith({ - value: 'bob', - label: 'Bob', - option: { label: 'Bob', value: 'bob' } - }) - }) - - it('supports keyboard selection from the input', async () => { - const combobox = new Combobox() - const changed = jest.fn() - - combobox.options = [ - { label: 'Alice', value: 'alice' }, - { label: 'Bob', value: 'bob' }, - { label: 'Carol', value: 'carol' } - ] - - combobox.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(combobox) - await combobox.updateComplete - - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) - await combobox.updateComplete - - expect(input.getAttribute('aria-expanded')).toBe('true') - expect(input.getAttribute('aria-activedescendant')).toBeTruthy() - - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) - await combobox.updateComplete - - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - await combobox.updateComplete - - expect(combobox.value).toBe('bob') - expect(combobox.inputValue).toBe('Bob') - expect(changed).toHaveBeenCalledWith({ - value: 'bob', - label: 'Bob', - option: { label: 'Bob', value: 'bob' } - }) - }) - - it('closes the popup when clicking outside the component', async () => { - const combobox = new Combobox() - combobox.options = [ - { label: 'Alice', value: 'alice' }, - { label: 'Bob', value: 'bob' } - ] - - document.body.appendChild(combobox) - await combobox.updateComplete - - const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement - input.dispatchEvent(new Event('focus')) - await combobox.updateComplete - - expect(input.getAttribute('aria-expanded')).toBe('true') - expect(getPortalRoot()).not.toBeNull() - - document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true })) - await combobox.updateComplete - - expect(input.getAttribute('aria-expanded')).toBe('false') - }) + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-combobox')).toBe(Combobox) + }) + + it('renders the input with label and placeholder', async () => { + const combobox = new Combobox() + combobox.label = 'Person' + combobox.placeholder = 'Search people' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const label = combobox.shadowRoot?.querySelector('label.text-label') as HTMLLabelElement + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + const toggle = combobox.shadowRoot?.querySelector('button.dropdown-toggle') as HTMLButtonElement + + expect(label).not.toBeNull() + expect(label.textContent).toContain('Person') + expect(input).not.toBeNull() + expect(input.placeholder).toBe('Search people') + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(toggle).not.toBeNull() + }) + + it('loads suggestions from suggestionProvider and emits input events', async () => { + const combobox = new Combobox() + const inputEvents = jest.fn() + const suggestionProvider = jest.fn(async (query: string) => [ + { label: `Alice ${query}`, value: 'alice' }, + { label: `Bob ${query}`, value: 'bob' } + ]) + + combobox.suggestionProvider = suggestionProvider + combobox.addEventListener('input', (event: Event) => { + inputEvents((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.value = 'al' + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) + + await flushUpdates() + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(suggestionProvider).toHaveBeenCalledWith('al') + expect(inputEvents).toHaveBeenCalledWith({ value: 'al' }) + expect(combobox.inputValue).toBe('al') + expect(options).toHaveLength(2) + expect(options[0].textContent).toContain('Alice al') + }) + + it('renders the selected option first in the popup', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + combobox.value = 'fr' + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = Array.from(portalRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + + it('updates value and emits change when an option is clicked', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice', publicId: 'https://example.com/alice' }, + { label: 'Bob', value: 'bob' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf + options[1].click() + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('supports keyboard selection from the input', async () => { + const combobox = new Combobox() + const changed = jest.fn() + + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' }, + { label: 'Carol', value: 'carol' } + ] + + combobox.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(input.getAttribute('aria-activedescendant')).toBeTruthy() + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await combobox.updateComplete + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await combobox.updateComplete + + expect(combobox.value).toBe('bob') + expect(combobox.inputValue).toBe('Bob') + expect(changed).toHaveBeenCalledWith({ + value: 'bob', + label: 'Bob', + option: { label: 'Bob', value: 'bob' } + }) + }) + + it('closes the popup when clicking outside the component', async () => { + const combobox = new Combobox() + combobox.options = [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' } + ] + + document.body.appendChild(combobox) + await combobox.updateComplete + + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(getPortalRoot()).not.toBeNull() + + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true, composed: true })) + await combobox.updateComplete + + expect(input.getAttribute('aria-expanded')).toBe('false') + }) }) diff --git a/src/v2/components/combobox/Combobox.ts b/src/v2/components/combobox/Combobox.ts index d9c6600a5..e790c7fbd 100644 --- a/src/v2/components/combobox/Combobox.ts +++ b/src/v2/components/combobox/Combobox.ts @@ -335,11 +335,11 @@ export class Combobox extends LitElement { } } - private _getSelectedIndex() { + private _getSelectedIndex () { return findOptionIndexByValue(this.options, this.value) } - private _getSelectedOption() { + private _getSelectedOption () { const selectedIndex = this._getSelectedIndex() if (selectedIndex >= 0) { @@ -349,7 +349,7 @@ export class Combobox extends LitElement { return this.options[0] } - private _getDisplayedOptions() { + private _getDisplayedOptions () { const selectedOption = this._getSelectedOption() if (!selectedOption) { @@ -362,7 +362,7 @@ export class Combobox extends LitElement { ] } - private _getActiveOption() { + private _getActiveOption () { const popupOptions = this._getDisplayedOptions() if (this._activeIndex < 0) { @@ -389,7 +389,7 @@ export class Combobox extends LitElement { this._openPopup() } - private _handleInputChange (e: Event) { + private async _handleInputChange (e: Event) { const query = (e.target as HTMLInputElement).value this.inputValue = query @@ -399,7 +399,7 @@ export class Combobox extends LitElement { bubbles: true, composed: true })) - void this._loadSuggestions(query) + await this._loadSuggestions(query) } private _handleInputKeydown (e: KeyboardEvent) { diff --git a/src/v2/components/combobox/index.ts b/src/v2/components/combobox/index.ts index cf45328bb..9630a0e62 100644 --- a/src/v2/components/combobox/index.ts +++ b/src/v2/components/combobox/index.ts @@ -1,4 +1,4 @@ -import { Combobox } from "./Combobox" +import { Combobox } from './Combobox' export { Combobox } diff --git a/src/v2/components/select/Select.test.ts b/src/v2/components/select/Select.test.ts index 65bdb2f7b..b56655246 100644 --- a/src/v2/components/select/Select.test.ts +++ b/src/v2/components/select/Select.test.ts @@ -3,149 +3,149 @@ import { Select } from './Select' import './index' describe('SolidUISelect', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-select')).toBe(Select) - }) - - it('renders the trigger with the first option label by default', async () => { - const select = new Select() - select.label = 'Language' - select.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' } - ] - - document.body.appendChild(select) - await select.updateComplete - - const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement - const triggerIcon = select.shadowRoot?.querySelector('.select-trigger-icon svg') as SVGElement - - expect(trigger).not.toBeNull() - expect(triggerIcon).not.toBeNull() - expect(trigger.getAttribute('aria-haspopup')).toBe('listbox') - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(trigger.textContent).toContain('English') - }) - - it('opens the popup and updates the value when an option is clicked', async () => { - const select = new Select() - const changed = jest.fn() - - select.label = 'Language' - select.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' } - ] - - select.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(select) - await select.updateComplete - - const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement - trigger.click() - await select.updateComplete - - const listbox = select.shadowRoot?.querySelector('[role="listbox"]') as HTMLElement - const options = select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf - - expect(listbox).not.toBeNull() - expect(options).toHaveLength(2) - - options[1].click() - await select.updateComplete - - expect(select.value).toBe('fr') - expect(trigger.textContent).toContain('French') - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(changed).toHaveBeenCalledWith({ value: 'fr' }) - }) - - it('renders the selected option first in the popup', async () => { - const select = new Select() - select.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' }, - { label: 'Spanish', value: 'es' } - ] - select.value = 'fr' - - document.body.appendChild(select) - await select.updateComplete - - const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement - trigger.click() - await select.updateComplete - - const options = Array.from(select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf) - - expect(options).toHaveLength(3) - expect(options[0].textContent).toContain('French') - expect(options[0].getAttribute('aria-selected')).toBe('true') - }) - it('supports keyboard selection from the trigger', async () => { - const select = new Select() - const changed = jest.fn() - - select.label = 'Language' - select.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' }, - { label: 'Spanish', value: 'es' } - ] - - select.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(select) - await select.updateComplete - - const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement - - trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) - await select.updateComplete - - expect(trigger.getAttribute('aria-expanded')).toBe('true') - expect(trigger.getAttribute('aria-activedescendant')).toBeTruthy() - - trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) - await select.updateComplete + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-select')).toBe(Select) + }) + + it('renders the trigger with the first option label by default', async () => { + const select = new Select() + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + const triggerIcon = select.shadowRoot?.querySelector('.select-trigger-icon svg') as SVGElement + + expect(trigger).not.toBeNull() + expect(triggerIcon).not.toBeNull() + expect(trigger.getAttribute('aria-haspopup')).toBe('listbox') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(trigger.textContent).toContain('English') + }) + + it('opens the popup and updates the value when an option is clicked', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const listbox = select.shadowRoot?.querySelector('[role="listbox"]') as HTMLElement + const options = select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf + + expect(listbox).not.toBeNull() + expect(options).toHaveLength(2) + + options[1].click() + await select.updateComplete + + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('renders the selected option first in the popup', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + select.value = 'fr' + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete + + const options = Array.from(select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf) + + expect(options).toHaveLength(3) + expect(options[0].textContent).toContain('French') + expect(options[0].getAttribute('aria-selected')).toBe('true') + }) + it('supports keyboard selection from the trigger', async () => { + const select = new Select() + const changed = jest.fn() + + select.label = 'Language' + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' } + ] + + select.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-activedescendant')).toBeTruthy() + + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await select.updateComplete - trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - await select.updateComplete + trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await select.updateComplete - expect(select.value).toBe('fr') - expect(trigger.textContent).toContain('French') - expect(changed).toHaveBeenCalledWith({ value: 'fr' }) - }) - - it('closes the popup when clicking outside the component', async () => { - const select = new Select() - select.options = [ - { label: 'English', value: 'en' }, - { label: 'French', value: 'fr' } - ] + expect(select.value).toBe('fr') + expect(trigger.textContent).toContain('French') + expect(changed).toHaveBeenCalledWith({ value: 'fr' }) + }) + + it('closes the popup when clicking outside the component', async () => { + const select = new Select() + select.options = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' } + ] - document.body.appendChild(select) - await select.updateComplete - - const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement - trigger.click() - await select.updateComplete + document.body.appendChild(select) + await select.updateComplete + + const trigger = select.shadowRoot?.querySelector('button.select-trigger') as HTMLButtonElement + trigger.click() + await select.updateComplete - expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(trigger.getAttribute('aria-expanded')).toBe('true') - document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) - await select.updateComplete + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + await select.updateComplete - expect(trigger.getAttribute('aria-expanded')).toBe('false') - }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) }) diff --git a/src/v2/components/select/Select.ts b/src/v2/components/select/Select.ts index d554628dc..c986babce 100644 --- a/src/v2/components/select/Select.ts +++ b/src/v2/components/select/Select.ts @@ -199,7 +199,7 @@ export class Select extends LitElement { private readonly _listboxId = `solid-ui-select-listbox-${Select._nextId++}` - constructor() { + constructor () { super() this.label = 'Select an option' this.theme = 'light' @@ -209,30 +209,30 @@ export class Select extends LitElement { this._activeIndex = -1 } - connectedCallback() { + connectedCallback () { super.connectedCallback() document.addEventListener('pointerdown', this._handleDocumentPointerDown) } - disconnectedCallback() { + disconnectedCallback () { document.removeEventListener('pointerdown', this._handleDocumentPointerDown) super.disconnectedCallback() } - protected updated() { + protected updated () { this.toggleAttribute('popup-open', this._popupOpen) } - private _closePopup() { + private _closePopup () { this._popupOpen = false this._activeIndex = -1 } - private _getSelectedIndex() { + private _getSelectedIndex () { return findOptionIndexByValue(this.options, this.value) } - private _getSelectedOption() { + private _getSelectedOption () { const selectedIndex = this._getSelectedIndex() if (selectedIndex >= 0) { @@ -242,7 +242,7 @@ export class Select extends LitElement { return this.options[0] } - private _getDisplayedOptions() { + private _getDisplayedOptions () { const selectedOption = this._getSelectedOption() if (!selectedOption) { @@ -255,7 +255,7 @@ export class Select extends LitElement { ] } - private _getActiveOption() { + private _getActiveOption () { const popupOptions = this._getDisplayedOptions() if (this._activeIndex < 0) { @@ -265,7 +265,7 @@ export class Select extends LitElement { return popupOptions[this._activeIndex] } - private _selectValueFromDropdown(uri: string) { + private _selectValueFromDropdown (uri: string) { this.value = uri this.dispatchEvent( new CustomEvent('change', { @@ -277,7 +277,7 @@ export class Select extends LitElement { this._closePopup() } - private _selectActiveOption() { + private _selectActiveOption () { const activeOption = this._getActiveOption() if (activeOption && !activeOption.disabled) { @@ -285,7 +285,7 @@ export class Select extends LitElement { } } - private _openPopup() { + private _openPopup () { const popupOptions = this._getDisplayedOptions() this._popupOpen = true @@ -296,7 +296,7 @@ export class Select extends LitElement { } } - private _handleTriggerKeydown(event: KeyboardEvent) { + private _handleTriggerKeydown (event: KeyboardEvent) { const popupOptions = this._getDisplayedOptions() const action = getListboxActionFromKey(event.key) @@ -356,11 +356,11 @@ export class Select extends LitElement { } } - private _getOptionId(option: SelectOption, index: number) { + private _getOptionId (option: SelectOption, index: number) { return `${this._listboxId}-option-${index}-${option.value}` } - private _renderPopup() { + private _renderPopup () { const popupOptions = this._getDisplayedOptions() const selectedOption = this._getSelectedOption() const activeOption = this._getActiveOption() @@ -382,7 +382,7 @@ export class Select extends LitElement { ` } - render() { + render () { const selectedOption = this._getSelectedOption() const activeOption = this._getActiveOption() const triggerLabel = selectedOption?.label ?? this.label diff --git a/src/v2/components/select/index.ts b/src/v2/components/select/index.ts index da95d70e1..258a5f293 100644 --- a/src/v2/components/select/index.ts +++ b/src/v2/components/select/index.ts @@ -1,4 +1,4 @@ -import { Select } from "./Select" +import { Select } from './Select' export { Select } diff --git a/src/v2/components/selectShared/downArrow.ts b/src/v2/components/selectShared/downArrow.ts index 57960daf1..fdd8ae8ea 100644 --- a/src/v2/components/selectShared/downArrow.ts +++ b/src/v2/components/selectShared/downArrow.ts @@ -7,4 +7,4 @@ export const downArrowIcon = html` > -` \ No newline at end of file +` diff --git a/src/v2/components/selectShared/keyboard.ts b/src/v2/components/selectShared/keyboard.ts index c74ae0cf4..d2d060726 100644 --- a/src/v2/components/selectShared/keyboard.ts +++ b/src/v2/components/selectShared/keyboard.ts @@ -1,7 +1,7 @@ -import { SelectOption } from "./optionTypes" +import { SelectOption } from './optionTypes' /* Move up or down, skip disabled options */ -export function getNextEnabledIndex( +export function getNextEnabledIndex ( currentIndex: number, options: SelectOption[], direction: 1 | -1 @@ -25,7 +25,7 @@ export function getNextEnabledIndex( } /* Handle 'Home' and 'End' keys and initial highlight */ -export function getFirstEnabledIndex(options: SelectOption[]): number { +export function getFirstEnabledIndex (options: SelectOption[]): number { if (!options.length) { return -1 } @@ -33,7 +33,7 @@ export function getFirstEnabledIndex(options: SelectOption[]): number { return getNextEnabledIndex(-1, options, 1) } -export function getLastEnabledIndex(options: SelectOption[]): number { +export function getLastEnabledIndex (options: SelectOption[]): number { if (!options.length) { return -1 } @@ -42,7 +42,7 @@ export function getLastEnabledIndex(options: SelectOption[]): number { } /* Sync current value to active index */ -export function findOptionIndexByValue( +export function findOptionIndexByValue ( options: SelectOption[], value?: string ): number { @@ -53,7 +53,7 @@ export function findOptionIndexByValue( } /* Map keyboard events to actions */ -export function getListboxActionFromKey(key: string): +export function getListboxActionFromKey (key: string): | 'open' | 'close' | 'next' @@ -79,4 +79,4 @@ export function getListboxActionFromKey(key: string): default: return 'none' } -} +} diff --git a/src/v2/components/selectShared/listboxTemplate.ts b/src/v2/components/selectShared/listboxTemplate.ts index 6c465c177..38d2ddfa4 100644 --- a/src/v2/components/selectShared/listboxTemplate.ts +++ b/src/v2/components/selectShared/listboxTemplate.ts @@ -10,7 +10,7 @@ export interface RenderListboxArgs { onOptionSelect: (option: SelectOption) => void } -export function renderListbox(args: RenderListboxArgs) { +export function renderListbox (args: RenderListboxArgs) { const { options, selectedOption, @@ -53,4 +53,4 @@ export function renderListbox(args: RenderListboxArgs) { })}
      ` -} \ No newline at end of file +} diff --git a/src/v2/components/selectShared/optionTypes.ts b/src/v2/components/selectShared/optionTypes.ts index 7aaa36d8d..e0c76398a 100644 --- a/src/v2/components/selectShared/optionTypes.ts +++ b/src/v2/components/selectShared/optionTypes.ts @@ -1,5 +1,5 @@ export interface SelectOption { - label: string - value: string - disabled?: boolean + label: string + value: string + disabled?: boolean } From 9711f44e9f04bb8f0ca1ffc631ae37ab87c168ab Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 29 Apr 2026 21:43:28 +1000 Subject: [PATCH 18/36] cleanup --- src/v2/components/combobox/Combobox.ts | 13 ------------- src/v2/components/combobox/README.md | 3 --- 2 files changed, 16 deletions(-) diff --git a/src/v2/components/combobox/Combobox.ts b/src/v2/components/combobox/Combobox.ts index e790c7fbd..f70a13478 100644 --- a/src/v2/components/combobox/Combobox.ts +++ b/src/v2/components/combobox/Combobox.ts @@ -63,10 +63,7 @@ export class Combobox extends LitElement { :host { // default theme display: inline-block; position: relative; - z-index: var(--combobox-z-index, 400); box-sizing: border-box; - --combobox-open-z-index: 1000; - --combobox-popup-z-index: 1001; --popup-background: var(--color-background, #F8F9FB); --popup-text: var(--color-text, #1A1A1A); --popup-border: var(--color-border, #E5E7EB); @@ -81,10 +78,7 @@ export class Combobox extends LitElement { :host([theme='dark']) { display: inline-block; position: relative; - z-index: var(--combobox-z-index, 900); box-sizing: border-box; - --combobox-open-z-index: 1000; - --combobox-popup-z-index: 1001; --popup-background: var(--color-background, #F8F9FB); --popup-text: var(--color-text, #1A1A1A); --popup-border: var(--color-border, #E5E7EB); @@ -96,10 +90,6 @@ export class Combobox extends LitElement { --placeholder-color: var(--grey-purple-700, #5e546d); } - :host([popup-open]) { - z-index: var(--combobox-open-z-index); - } - .popup-box { position: absolute; top: 0; @@ -114,7 +104,6 @@ export class Combobox extends LitElement { overflow: hidden; box-sizing: border-box; isolation: isolate; - z-index: var(--combobox-popup-z-index); } .select-options-section { @@ -316,8 +305,6 @@ export class Combobox extends LitElement { } protected updated (changedProperties: Map) { - this.toggleAttribute('popup-open', this._popupOpen) - if (this._popupOpen) { this._updatePopupPosition() if (this._popupPortalRoot) { diff --git a/src/v2/components/combobox/README.md b/src/v2/components/combobox/README.md index a5b57de99..fe963ba98 100644 --- a/src/v2/components/combobox/README.md +++ b/src/v2/components/combobox/README.md @@ -127,9 +127,6 @@ These can be set on `solid-ui-combobox`, on a container element, or on `:root`. | Variable | Fallback | Description | |----------|----------|-------------| -| `--combobox-z-index` | `400` / `900` in dark theme | Base host stacking level before the popup opens. | -| `--combobox-open-z-index` | `1000` | Host stacking level while the popup is open. | -| `--combobox-popup-z-index` | `1001` | Popup stacking level inside the open host or portal. | | `--popup-background` | `--color-background` | Popup surface background. | | `--popup-text` | `--color-text` | Popup text colour. | | `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour. | From fe509c08d5439870c8ab6555cb8a61a94c0d9186 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 30 Apr 2026 13:26:49 +1000 Subject: [PATCH 19/36] photoCapture web component --- README.md | 4 +- package.json | 5 + .../photoCapture/PhotoCapture.test.ts | 177 ++++ .../components/photoCapture/PhotoCapture.ts | 821 ++++++++++++++++++ src/v2/components/photoCapture/README.md | 78 ++ src/v2/components/photoCapture/index.ts | 9 + webpack.config.mjs | 3 + 7 files changed, 1096 insertions(+), 1 deletion(-) create mode 100644 src/v2/components/photoCapture/PhotoCapture.test.ts create mode 100644 src/v2/components/photoCapture/PhotoCapture.ts create mode 100644 src/v2/components/photoCapture/README.md create mode 100644 src/v2/components/photoCapture/index.ts diff --git a/README.md b/README.md index 0ca9b43b0..41b1d176e 100644 --- a/README.md +++ b/README.md @@ -413,4 +413,6 @@ You are logged in as nameOfLoggedIn user. * Claude Sonnet 4.6: Make the dop down as a list under the input field and entlarge the pop up, make it higher, adjustable to fit the drop down. And make the drop down arrow area larger -* GPT-5.4: can you wire up the keyboard interactions and aria attributes for Select. \ No newline at end of file +* GPT-5.4 Model: can you wire up the keyboard interactions and aria attributes for Select. + +* GPT-5.4 Model: Take the code from /Users/sharon/2025Dev/solid-ui/src/media/media-capture.ts and make it a web component. Make it work in forms as well as not. Make it configurable and follow LoginButton. diff --git a/package.json b/package.json index 2f3b3a46a..bc3a9e34e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "import": "./dist/components/signupButton/index.esm.js", "require": "./dist/components/signupButton/index.js" }, + "./components/photo-capture": { + "types": "./dist/components/photoCapture/index.d.ts", + "import": "./dist/components/photoCapture/index.esm.js", + "require": "./dist/components/photoCapture/index.js" + }, "./components/footer": { "types": "./dist/components/footer/index.d.ts", "import": "./dist/components/footer/index.esm.js", diff --git a/src/v2/components/photoCapture/PhotoCapture.test.ts b/src/v2/components/photoCapture/PhotoCapture.test.ts new file mode 100644 index 000000000..a879a9f3f --- /dev/null +++ b/src/v2/components/photoCapture/PhotoCapture.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { PhotoCapture } from './PhotoCapture' +import './index' + +describe('SolidUIPhotoCapture', () => { + const stopTrack = jest.fn() + const getUserMedia: any = jest.fn() + + beforeEach(() => { + document.body.innerHTML = '' + stopTrack.mockReset() + getUserMedia.mockReset() + getUserMedia.mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + getVideoTracks: () => [{ stop: stopTrack }] + }) + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + get () { + return (this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject ?? null + }, + set (value) { + ;(this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject = value as MediaStream | null + } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn(() => Promise.resolve(undefined)) + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: jest.fn(() => ({ drawImage: jest.fn() })) + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + configurable: true, + value: jest.fn((callback: BlobCallback, type?: string) => { + callback(new Blob(['photo'], { type: type || 'image/png' })) + }) + }) + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: jest.fn(() => 'blob:test-photo') + }) + + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: jest.fn() + }) + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) + }) + + it('starts the preview inline using the default environment-facing video constraint', async () => { + const photoCapture = new PhotoCapture() + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + facingMode: { ideal: 'environment' } + } + }) + }) + + it('accepts dialog presentation and custom constraints JSON', async () => { + const photoCapture = new PhotoCapture() + photoCapture.presentation = 'dialog' + photoCapture.open = false + photoCapture.constraints = JSON.stringify({ video: true, audio: false }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + + const trigger = photoCapture.shadowRoot?.querySelector('button.trigger-button') as HTMLButtonElement + trigger.click() + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(photoCapture.open).toBe(true) + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() + expect(getUserMedia).toHaveBeenCalledWith({ video: true, audio: false }) + }) + + it('dispatches a photo-captured event with the confirmed blob', async () => { + const photoCapture = new PhotoCapture() + const captured = jest.fn() + const changed = jest.fn() + + photoCapture.addEventListener('photo-captured', (event: Event) => { + captured((event as CustomEvent).detail) + }) + photoCapture.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + + const confirmButton = photoCapture.shadowRoot?.querySelector('[part="confirm-button"]') as HTMLButtonElement + confirmButton.click() + + expect(captured).toHaveBeenCalledWith({ + file: expect.any(File), + blob: expect.any(Blob), + objectUrl: 'blob:test-photo', + contentType: 'image/png' + }) + expect(photoCapture.value).toBeInstanceOf(File) + expect(changed).toHaveBeenCalledWith({ value: photoCapture.value }) + }) + + it('can participate in a form-like submission while still exposing a value property', async () => { + const form = document.createElement('form') + const photoCapture = new PhotoCapture() + photoCapture.name = 'avatar' + form.appendChild(photoCapture) + document.body.appendChild(form) + + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + ;(photoCapture as any)._confirmPhoto() + + expect(photoCapture.value).toBeInstanceOf(File) + + const formData = new FormData() + const formDataEvent = new Event('formdata') as Event & { formData: FormData } + formDataEvent.formData = formData + form.dispatchEvent(formDataEvent) + + const submitted = formData.get('avatar') + expect(submitted).toBeInstanceOf(File) + expect((submitted as File).name).toContain('avatar-') + }) +}) diff --git a/src/v2/components/photoCapture/PhotoCapture.ts b/src/v2/components/photoCapture/PhotoCapture.ts new file mode 100644 index 000000000..a5103dd2f --- /dev/null +++ b/src/v2/components/photoCapture/PhotoCapture.ts @@ -0,0 +1,821 @@ +import { LitElement, css, html, nothing, type PropertyValues } from 'lit' +/* The original code was written by Sir Tim Berners-Lee. It was made into a +web component by AI Model GPT-5.4 +Prompt: Take the code from src/media/media-capture.ts and make it a +web component. Make it work in forms as well as not. Make it +configurable and follow LoginButton. */ +export interface PhotoCapturedDetail { + file: File + blob: Blob + objectUrl: string + contentType: string +} + +export interface PhotoCaptureErrorDetail { + error: unknown + message: string +} + +export interface PhotoCaptureOpenChangeDetail { + open: boolean +} + +export interface PhotoCaptureValueDetail { + value: File | null +} + +type PresentationMode = 'inline' | 'dialog' +type ThemeMode = 'light' | 'dark' + +const DEFAULT_CAPTURE_FORMAT = 'image/png' + +export class PhotoCapture extends LitElement { + static formAssociated = true + + static properties = { + label: { type: String, reflect: true }, + heading: { type: String, reflect: true }, + captureLabel: { type: String, attribute: 'capture-label', reflect: true }, + confirmLabel: { type: String, attribute: 'confirm-label', reflect: true }, + retakeLabel: { type: String, attribute: 'retake-label', reflect: true }, + cancelLabel: { type: String, attribute: 'cancel-label', reflect: true }, + presentation: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + facingMode: { type: String, attribute: 'facing-mode', reflect: true }, + constraints: { type: String, reflect: true }, + captureFormat: { type: String, attribute: 'capture-format', reflect: true }, + captureQuality: { type: Number, attribute: 'capture-quality' }, + open: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + name: { type: String, reflect: true }, + required: { type: Boolean, reflect: true }, + showTrigger: { type: Boolean, attribute: 'show-trigger', reflect: true }, + showCancelButton: { type: Boolean, attribute: 'show-cancel-button', reflect: true }, + autoCloseOnCapture: { type: Boolean, attribute: 'auto-close-on-capture' }, + fileNamePrefix: { type: String, attribute: 'file-name-prefix', reflect: true }, + value: { attribute: false }, + mediaConstraints: { attribute: false }, + _errorMessage: { state: true }, + _previewUrl: { state: true }, + _startingPreview: { state: true } + } + + static styles = css` + :host { + display: block; + --photo-capture-trigger-background: var(--lavender-900, #7c4cff); + --photo-capture-trigger-text: var(--color-header-text, #ffffff); + --photo-capture-surface: var(--color-background, #ffffff); + --photo-capture-text: var(--gray-900, #101828); + --photo-capture-muted-text: var(--gray-600, #4a5565); + --photo-capture-border: var(--gray-200, #e5e7eb); + --photo-capture-hover: var(--gray-100, #f3f4f6); + --photo-capture-shadow: var(--box-shadow-sm, 0 1px 4px rgba(0, 0, 0, 0.12)); + --photo-capture-overlay: rgba(0, 0, 0, 0.6); + --photo-capture-frame-max-width: 260px; + --photo-capture-radius: 8px; + --photo-capture-button-radius: var(--border-radius-base, 0.3125rem); + --photo-capture-gap: var(--spacing-2xs, 0.625rem); + color: var(--photo-capture-text); + box-sizing: border-box; + } + + :host([theme='dark']) { + --photo-capture-surface: var(--gray-900, #111827); + --photo-capture-text: var(--white, #ffffff); + --photo-capture-muted-text: var(--gray-300, #d1d5dc); + --photo-capture-border: var(--gray-700, #364153); + --photo-capture-hover: rgba(255, 255, 255, 0.08); + --photo-capture-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + } + + *, *::before, *::after { + box-sizing: border-box; + } + + .trigger-button, + .action-button, + .cancel-button, + .close-button { + font: inherit; + cursor: pointer; + } + + .trigger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 35px; + padding: 0.5rem 0.9rem; + border: none; + border-radius: var(--photo-capture-button-radius); + background: var(--photo-capture-trigger-background); + color: var(--photo-capture-trigger-text); + transition: transform 0.2s ease; + } + + .trigger-button:active { + transform: translateY(1px); + } + + .trigger-button:disabled, + .action-button:disabled, + .cancel-button:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .inline-root[hidden] { + display: none; + } + + .dialog { + border: none; + padding: 0; + background: transparent; + outline: none; + overflow: visible; + max-width: none; + max-height: none; + } + + .dialog::backdrop { + background: var(--photo-capture-overlay); + } + + .panel { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--photo-capture-gap); + width: min(100%, 340px); + padding: 1rem; + border: 1px solid var(--photo-capture-border); + border-radius: var(--photo-capture-radius); + background: var(--photo-capture-surface); + color: var(--photo-capture-text); + box-shadow: var(--photo-capture-shadow); + } + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + } + + .panel-heading { + margin: 0; + font-size: 1rem; + font-weight: 700; + line-height: 1.4; + } + + .close-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--photo-capture-muted-text); + font-size: 1.125rem; + line-height: 1; + } + + .close-button:hover, + .close-button:focus-visible, + .action-button:hover, + .action-button:focus-visible, + .cancel-button:hover, + .cancel-button:focus-visible { + background: var(--photo-capture-hover); + } + + .viewport { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 200px; + border-radius: 0.5rem; + overflow: hidden; + background: color-mix(in srgb, var(--photo-capture-surface) 92%, #000 8%); + } + + .viewport video, + .viewport img { + display: block; + width: 100%; + max-width: var(--photo-capture-frame-max-width); + height: auto; + border-radius: 0.5rem; + margin: 0 auto; + object-fit: cover; + } + + .status { + width: 100%; + text-align: center; + color: var(--photo-capture-muted-text); + font-size: 0.875rem; + } + + .status.error { + color: var(--color-error, #b00020); + } + + .actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--photo-capture-gap); + width: 100%; + } + + .action-button, + .cancel-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.25rem; + padding: 0.45rem 0.85rem; + border-radius: var(--photo-capture-button-radius); + border: 1px solid var(--photo-capture-border); + background: var(--photo-capture-surface); + color: var(--photo-capture-text); + font-size: var(--font-size-xxs, 0.75rem); + font-weight: var(--font-weight-xbold, 700); + line-height: 1.5; + } + + .action-button--primary { + background: var(--photo-capture-trigger-background); + color: var(--photo-capture-trigger-text); + border-color: transparent; + } + ` + + declare label: string + declare heading: string + declare captureLabel: string + declare confirmLabel: string + declare retakeLabel: string + declare cancelLabel: string + declare presentation: PresentationMode + declare theme: ThemeMode + declare facingMode: string + declare constraints: string + declare captureFormat: string + declare captureQuality?: number + declare open: boolean + declare disabled: boolean + declare name: string + declare required: boolean + declare showTrigger: boolean + declare showCancelButton: boolean + declare autoCloseOnCapture: boolean + declare fileNamePrefix: string + declare mediaConstraints?: MediaStreamConstraints + declare _errorMessage: string + declare _previewUrl: string + declare _startingPreview: boolean + + private _value: File | null = null + private _stream: MediaStream | null = null + private readonly _internals: ElementInternals | null + private _associatedForm: HTMLFormElement | null = null + private readonly _handleFormData = (event: Event) => { + const formData = (event as Event & { formData?: FormData }).formData + if (!formData || !this.name || !this.value || this.disabled) return + formData.append(this.name, this.value, this.value.name) + } + + private readonly _handleFormReset = () => { + this._clearValue({ emitEvents: false }) + if (this.open) { + void this._startPreview() + } + } + + private get _supportsFormInternals (): boolean { + return !!this._internals && typeof this._internals.setFormValue === 'function' + } + + constructor () { + super() + this.label = 'Take Photo' + this.heading = 'Take a photo' + this.captureLabel = 'Take Photo' + this.confirmLabel = 'Use Photo' + this.retakeLabel = 'Retake' + this.cancelLabel = 'Cancel' + this.presentation = 'inline' + this.theme = 'light' + this.facingMode = 'environment' + this.constraints = '' + this.captureFormat = DEFAULT_CAPTURE_FORMAT + this.captureQuality = undefined + this.open = true + this.disabled = false + this.name = '' + this.required = false + this.showTrigger = false + this.showCancelButton = true + this.autoCloseOnCapture = false + this.fileNamePrefix = '' + this.mediaConstraints = undefined + this._errorMessage = '' + this._previewUrl = '' + this._startingPreview = false + this._internals = typeof this.attachInternals === 'function' ? this.attachInternals() : null + } + + get value (): File | null { + return this._value + } + + set value (nextValue: File | null) { + const normalizedValue = nextValue instanceof File ? nextValue : null + const previousValue = this._value + if (previousValue === normalizedValue) return + + this._value = normalizedValue + this._syncPreviewFromValue(normalizedValue) + this._syncFormValue() + this._syncValidity() + this.requestUpdate('value', previousValue) + } + + get form (): HTMLFormElement | null { + return (this._supportsFormInternals ? this._internals?.form : null) ?? this._associatedForm + } + + get validationMessage (): string { + return (typeof this._internals?.validationMessage === 'string' ? this._internals.validationMessage : '') || (this.required && !this.value ? 'Please capture a photo.' : '') + } + + get willValidate (): boolean { + return typeof this._internals?.willValidate === 'boolean' ? this._internals.willValidate : !this.disabled + } + + checkValidity (): boolean { + if (this._internals && typeof this._internals.checkValidity === 'function') { + return this._internals.checkValidity() + } + return !(this.required && !this.value) + } + + reportValidity (): boolean { + if (this._internals && typeof this._internals.reportValidity === 'function') { + return this._internals.reportValidity() + } + return this.checkValidity() + } + + connectedCallback () { + super.connectedCallback() + this._syncAssociatedForm() + this._syncFormValue() + this._syncValidity() + } + + disconnectedCallback () { + this._syncAssociatedForm(null) + this._stopStream() + this._revokePreviewUrl() + super.disconnectedCallback() + } + + formResetCallback () { + this._handleFormReset() + } + + formDisabledCallback (disabled: boolean) { + this.disabled = disabled + } + + protected updated (changed: PropertyValues) { + this._syncAssociatedForm() + + if (this.presentation === 'dialog') { + const dialog = this.shadowRoot?.querySelector('dialog') as HTMLDialogElement | null + if (dialog) { + if (this.open && !dialog.open) { + dialog.showModal() + } else if (!this.open && dialog.open) { + dialog.close() + } + } + } + + if (changed.has('open') && !this.open) { + this._stopStream() + } + + if ( + this.open && + !this.value && + !this._stream && + !this._startingPreview && + (changed.has('open') || changed.has('presentation') || changed.has('_previewUrl') || changed.has('value')) + ) { + void this._startPreview() + } + + if (changed.has('name') || changed.has('disabled') || changed.has('value')) { + this._syncFormValue() + } + + if (changed.has('required') || changed.has('disabled') || changed.has('value')) { + this._syncValidity() + } + + if (this._stream) { + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video && video.srcObject !== this._stream) { + video.srcObject = this._stream + } + } + } + + private _setOpen (open: boolean) { + if (this.open === open) return + this.open = open + this.dispatchEvent(new CustomEvent('open-change', { + detail: { open }, + bubbles: true, + composed: true + })) + } + + private _emitError (error: unknown, message = 'Unable to access the camera') { + this._errorMessage = message + this.dispatchEvent(new CustomEvent('error', { + detail: { error, message }, + bubbles: true, + composed: true + })) + } + + private _syncAssociatedForm (nextForm = this.closest('form') as HTMLFormElement | null) { + if (this._associatedForm === nextForm) return + + if (this._associatedForm) { + this._associatedForm.removeEventListener('formdata', this._handleFormData) + this._associatedForm.removeEventListener('reset', this._handleFormReset) + } + + this._associatedForm = nextForm + + if (this._associatedForm && !this._supportsFormInternals) { + this._associatedForm.addEventListener('formdata', this._handleFormData) + this._associatedForm.addEventListener('reset', this._handleFormReset) + } + } + + private _syncFormValue () { + if (!this._supportsFormInternals) return + const internals = this._internals + if (!internals) return + if (this.disabled || !this.name || !this.value) { + internals.setFormValue(null) + return + } + internals.setFormValue(this.value) + } + + private _syncValidity () { + if (!this._internals || !this._supportsFormInternals || typeof this._internals.setValidity !== 'function') return + if (this.disabled || !this.required || this.value) { + this._internals.setValidity({}) + return + } + this._internals.setValidity({ valueMissing: true }, 'Please capture a photo.') + } + + private _syncPreviewFromValue (file: File | null) { + this._revokePreviewUrl() + if (!file) return + this._stopStream() + this._previewUrl = URL.createObjectURL(file) + } + + private _clearValue (options: { emitEvents: boolean }) { + this.value = null + this._errorMessage = '' + if (options.emitEvents) { + this._dispatchValueEvents() + } + } + + private _dispatchValueEvents () { + const detail = { value: this.value } + this.dispatchEvent(new CustomEvent('input', { + detail, + bubbles: true, + composed: true + })) + this.dispatchEvent(new CustomEvent('change', { + detail, + bubbles: true, + composed: true + })) + } + + private _fileExtensionForMimeType (mimeType: string): string { + switch (mimeType) { + case 'image/jpeg': + return 'jpg' + case 'image/webp': + return 'webp' + case 'image/gif': + return 'gif' + default: + return 'png' + } + } + + private _createFileFromBlob (blob: Blob): File { + const contentType = blob.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT + const extension = this._fileExtensionForMimeType(contentType) + const safePrefix = (this.fileNamePrefix || this.name || 'photo').trim() || 'photo' + return new File([blob], `${safePrefix}-${Date.now()}.${extension}`, { type: contentType }) + } + + private _resolveMediaConstraints (): MediaStreamConstraints { + if (this.mediaConstraints) { + return this.mediaConstraints + } + if (this.constraints) { + try { + return JSON.parse(this.constraints) as MediaStreamConstraints + } catch (error) { + throw new Error(`Invalid constraints JSON: ${(error as Error).message}`) + } + } + + return { + video: this.facingMode + ? { facingMode: { ideal: this.facingMode } } + : true + } + } + + private async _startPreview () { + if (!this.open || this.value || this._startingPreview) return + if (!navigator.mediaDevices?.getUserMedia) { + this._emitError(new Error('navigator.mediaDevices.getUserMedia not available'), 'Camera access is not available in this browser') + return + } + + this._startingPreview = true + this._errorMessage = '' + + try { + const stream = await navigator.mediaDevices.getUserMedia(this._resolveMediaConstraints()) + if (!this.open) { + stream.getTracks().forEach(track => track.stop()) + return + } + this._stream = stream + this.requestUpdate() + await this.updateComplete + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video) { + video.srcObject = stream + await video.play?.().catch(() => undefined) + } + } catch (error) { + this._emitError(error, (error as Error)?.message || 'Unable to start the camera preview') + } finally { + this._startingPreview = false + } + } + + private _stopStream () { + if (!this._stream) return + this._stream.getTracks().forEach(track => track.stop()) + this._stream = null + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (video) { + video.srcObject = null + } + } + + private _revokePreviewUrl () { + if (this._previewUrl) { + URL.revokeObjectURL(this._previewUrl) + } + this._previewUrl = '' + } + + private async _captureSnapshot () { + const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null + if (!video) return + + const width = video.videoWidth || video.clientWidth || 640 + const height = video.videoHeight || video.clientHeight || 480 + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const context = canvas.getContext('2d') + if (!context) { + this._emitError(new Error('Canvas 2D context unavailable'), 'Unable to capture a photo in this browser') + return + } + + context.drawImage(video, 0, 0, width, height) + + const blob = await new Promise(resolve => { + canvas.toBlob(resolve, this.captureFormat || DEFAULT_CAPTURE_FORMAT, this.captureQuality) + }) + + if (!blob) { + this._emitError(new Error('Camera snapshot failed'), 'Unable to create an image from the current camera frame') + return + } + + this.value = this._createFileFromBlob(blob) + this._errorMessage = '' + } + + private async _retakePhoto () { + this._clearValue({ emitEvents: true }) + await this._startPreview() + } + + private _confirmPhoto () { + if (!this.value || !this._previewUrl) return + + this._dispatchValueEvents() + + this.dispatchEvent(new CustomEvent('photo-captured', { + detail: { + file: this.value, + blob: this.value, + objectUrl: this._previewUrl, + contentType: this.value.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT + }, + bubbles: true, + composed: true + })) + + if (this.autoCloseOnCapture) { + this._setOpen(false) + } + } + + private _handleCancel () { + this._stopStream() + this._clearValue({ emitEvents: false }) + this._setOpen(false) + this.dispatchEvent(new CustomEvent('cancel', { + bubbles: true, + composed: true + })) + } + + private _openCapture () { + if (this.disabled) return + this._setOpen(true) + } + + private _renderViewport () { + if (this._previewUrl) { + return html`Captured photo preview` + } + + return html`` + } + + private _renderStatus () { + if (this._errorMessage) { + return html`
      ${this._errorMessage}
      ` + } + + if (this._startingPreview) { + return html`
      Opening camera…
      ` + } + + if (!this.value) { + return html`
      Preview the camera and take a photo when ready.
      ` + } + + return html`
      Review the photo before confirming it.
      ` + } + + private _renderActions () { + return html` +
      + ${this.showCancelButton + ? html` + + ` + : nothing} + + ${this.value + ? html` + + + ` + : html` + + `} +
      + ` + } + + private _renderPanel () { + return html` +
      +
      +

      ${this.heading}

      + ${this.showCancelButton + ? html` + + ` + : nothing} +
      +
      ${this._renderViewport()}
      + ${this._renderStatus()} + ${this._renderActions()} +
      + ` + } + + render () { + const trigger = this.showTrigger || this.presentation === 'dialog' + + return html` + ${trigger + ? html` + + ` + : nothing} + + ${this.presentation === 'dialog' + ? html` + + ${this.open ? this._renderPanel() : nothing} + + ` + : html` +
      + ${this.open ? this._renderPanel() : nothing} +
      + `} + ` + } +} diff --git a/src/v2/components/photoCapture/README.md b/src/v2/components/photoCapture/README.md new file mode 100644 index 000000000..f44427401 --- /dev/null +++ b/src/v2/components/photoCapture/README.md @@ -0,0 +1,78 @@ +# solid-ui-photo-capture component + +A Lit-based camera capture web component that can render inline on a page or inside a modal dialog. It opens the device camera, lets the user take a photo, review it, retake it, and then exposes the confirmed image both as a form-like `value` and as browser events. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage + +```javascript +import { PhotoCapture } from 'solid-ui/components/photo-capture' +``` + +```html + + + +``` + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|---|---|---|---|---| +| `label` | `label` | `string` | `Take Photo` | Trigger button label. Ignored when `show-trigger` is false and `presentation="inline"`. | +| `heading` | `heading` | `string` | `Take a photo` | Panel heading. | +| `presentation` | `presentation` | `'inline' \| 'dialog'` | `'inline'` | Controls whether the capture UI sits in-page or inside a native dialog. | +| `open` | `open` | `boolean` | `true` | Controls whether the capture panel is visible. | +| `name` | `name` | `string` | `''` | Form field name used when the component participates in form submission. | +| `required` | `required` | `boolean` | `false` | Marks the control as required for form validation. | +| `value` | none | `File \| null` | `null` | The current captured file. Settable from JavaScript. | +| `showTrigger` | `show-trigger` | `boolean` | `false` | Shows a trigger button that opens the capture UI. | +| `showCancelButton` | `show-cancel-button` | `boolean` | `true` | Shows the cancel and close controls. | +| `facingMode` | `facing-mode` | `string` | `environment` | Convenience control for camera selection when custom constraints are not provided. | +| `constraints` | `constraints` | `string` | `''` | JSON string for full `MediaStreamConstraints`, for example `{ "video": true }`. | +| `mediaConstraints` | none | `MediaStreamConstraints` | `undefined` | JS-only property for passing constraints directly. Overrides `constraints`. | +| `captureFormat` | `capture-format` | `string` | `image/png` | Output MIME type used for `canvas.toBlob()`. | +| `captureQuality` | `capture-quality` | `number` | `undefined` | Optional quality value for formats that support it, such as JPEG or WebP. | +| `fileNamePrefix` | `file-name-prefix` | `string` | `''` | Prefix used when the component generates a `File` name for the captured image. If omitted, the component falls back to `name` and then `photo`. | +| `autoCloseOnCapture` | `auto-close-on-capture` | `boolean` | `false` | Closes the component after the user confirms a photo. | + +### Events + +| Event | Detail | Description | +|---|---|---| +| `input` | `{ value: File \| null }` | Fired when the component updates its current file value. | +| `change` | `{ value: File \| null }` | Fired when the user confirms or clears the current file value. | +| `photo-captured` | `{ file, blob, objectUrl, contentType }` | Fired when the user confirms the captured photo. | +| `open-change` | `{ open }` | Fired whenever the component opens or closes itself. | +| `cancel` | none | Fired when the user cancels the capture flow. | +| `error` | `{ error, message }` | Fired when camera access or capture fails. | + +### Slots + +| Slot | Description | +|---|---| +| default | Replaces the trigger button label. | +| `heading` | Replaces the panel heading. | + +## Notes + +- Inline mode is the default, so the component can be embedded directly inside a page or form. +- Dialog mode uses the native `` element and is useful when the capture flow should float above the current page. +- The component does not upload the photo itself. Consumers can persist it by reading `value`, listening for `change`, or handling `photo-captured`. +- When form-associated custom elements are supported, the component uses `ElementInternals`. Otherwise it still supports form-style submission via the form's `formdata` event. diff --git a/src/v2/components/photoCapture/index.ts b/src/v2/components/photoCapture/index.ts new file mode 100644 index 000000000..8c23c459a --- /dev/null +++ b/src/v2/components/photoCapture/index.ts @@ -0,0 +1,9 @@ +import { PhotoCapture } from './PhotoCapture' + +export { PhotoCapture } + +const PHOTO_CAPTURE_TAG_NAME = 'solid-ui-photo-capture' + +if (!customElements.get(PHOTO_CAPTURE_TAG_NAME)) { + customElements.define(PHOTO_CAPTURE_TAG_NAME, PhotoCapture) +} diff --git a/webpack.config.mjs b/webpack.config.mjs index b2b2bba0f..73904b76f 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -40,6 +40,9 @@ const common = { signupButton: { import: './src/v2/components/signupButton/index.ts' }, + photoCapture: { + import: './src/v2/components/photoCapture/index.ts' + }, footer: { import: './src/v2/components/footer/index.ts' }, From 84053fdf30295ba981588624c4b742575808738e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 30 Apr 2026 13:34:03 +1000 Subject: [PATCH 20/36] lint fix --- .../photoCapture/PhotoCapture.test.ts | 342 +++++++++--------- .../components/photoCapture/PhotoCapture.ts | 14 +- 2 files changed, 180 insertions(+), 176 deletions(-) diff --git a/src/v2/components/photoCapture/PhotoCapture.test.ts b/src/v2/components/photoCapture/PhotoCapture.test.ts index a879a9f3f..099fbba5d 100644 --- a/src/v2/components/photoCapture/PhotoCapture.test.ts +++ b/src/v2/components/photoCapture/PhotoCapture.test.ts @@ -3,175 +3,175 @@ import { PhotoCapture } from './PhotoCapture' import './index' describe('SolidUIPhotoCapture', () => { - const stopTrack = jest.fn() - const getUserMedia: any = jest.fn() - - beforeEach(() => { - document.body.innerHTML = '' - stopTrack.mockReset() - getUserMedia.mockReset() - getUserMedia.mockResolvedValue({ - getTracks: () => [{ stop: stopTrack }], - getVideoTracks: () => [{ stop: stopTrack }] - }) - - Object.defineProperty(navigator, 'mediaDevices', { - configurable: true, - value: { getUserMedia } - }) - - Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { - configurable: true, - get () { - return (this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject ?? null - }, - set (value) { - ;(this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject = value as MediaStream | null - } - }) - - Object.defineProperty(HTMLMediaElement.prototype, 'play', { - configurable: true, - value: jest.fn(() => Promise.resolve(undefined)) - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { - configurable: true, - value: jest.fn() - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'close', { - configurable: true, - value: jest.fn() - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: jest.fn(() => ({ drawImage: jest.fn() })) - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - configurable: true, - value: jest.fn((callback: BlobCallback, type?: string) => { - callback(new Blob(['photo'], { type: type || 'image/png' })) - }) - }) - - Object.defineProperty(URL, 'createObjectURL', { - configurable: true, - value: jest.fn(() => 'blob:test-photo') - }) - - Object.defineProperty(URL, 'revokeObjectURL', { - configurable: true, - value: jest.fn() - }) - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) - }) - - it('starts the preview inline using the default environment-facing video constraint', async () => { - const photoCapture = new PhotoCapture() - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - expect(getUserMedia).toHaveBeenCalledWith({ - video: { - facingMode: { ideal: 'environment' } - } - }) - }) - - it('accepts dialog presentation and custom constraints JSON', async () => { - const photoCapture = new PhotoCapture() - photoCapture.presentation = 'dialog' - photoCapture.open = false - photoCapture.constraints = JSON.stringify({ video: true, audio: false }) - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - - const trigger = photoCapture.shadowRoot?.querySelector('button.trigger-button') as HTMLButtonElement - trigger.click() - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(true) - expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() - expect(getUserMedia).toHaveBeenCalledWith({ video: true, audio: false }) - }) - - it('dispatches a photo-captured event with the confirmed blob', async () => { - const photoCapture = new PhotoCapture() - const captured = jest.fn() - const changed = jest.fn() - - photoCapture.addEventListener('photo-captured', (event: Event) => { - captured((event as CustomEvent).detail) - }) - photoCapture.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement - Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) - Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) - - await (photoCapture as any)._captureSnapshot() - await photoCapture.updateComplete - - const confirmButton = photoCapture.shadowRoot?.querySelector('[part="confirm-button"]') as HTMLButtonElement - confirmButton.click() - - expect(captured).toHaveBeenCalledWith({ - file: expect.any(File), - blob: expect.any(Blob), - objectUrl: 'blob:test-photo', - contentType: 'image/png' - }) - expect(photoCapture.value).toBeInstanceOf(File) - expect(changed).toHaveBeenCalledWith({ value: photoCapture.value }) - }) - - it('can participate in a form-like submission while still exposing a value property', async () => { - const form = document.createElement('form') - const photoCapture = new PhotoCapture() - photoCapture.name = 'avatar' - form.appendChild(photoCapture) - document.body.appendChild(form) - - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement - Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) - Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) - - await (photoCapture as any)._captureSnapshot() - await photoCapture.updateComplete - ;(photoCapture as any)._confirmPhoto() - - expect(photoCapture.value).toBeInstanceOf(File) - - const formData = new FormData() - const formDataEvent = new Event('formdata') as Event & { formData: FormData } - formDataEvent.formData = formData - form.dispatchEvent(formDataEvent) - - const submitted = formData.get('avatar') - expect(submitted).toBeInstanceOf(File) - expect((submitted as File).name).toContain('avatar-') - }) + const stopTrack = jest.fn() + const getUserMedia: any = jest.fn() + + beforeEach(() => { + document.body.innerHTML = '' + stopTrack.mockReset() + getUserMedia.mockReset() + getUserMedia.mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + getVideoTracks: () => [{ stop: stopTrack }] + }) + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + get () { + return (this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject ?? null + }, + set (value) { + ;(this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject = value as MediaStream | null + } + }) + + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn(() => Promise.resolve(undefined)) + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value: jest.fn() + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: jest.fn(() => ({ drawImage: jest.fn() })) + }) + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + configurable: true, + value: jest.fn((callback: BlobCallback, type?: string) => { + callback(new Blob(['photo'], { type: type || 'image/png' })) + }) + }) + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: jest.fn(() => 'blob:test-photo') + }) + + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: jest.fn() + }) + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) + }) + + it('starts the preview inline using the default environment-facing video constraint', async () => { + const photoCapture = new PhotoCapture() + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + facingMode: { ideal: 'environment' } + } + }) + }) + + it('accepts dialog presentation and custom constraints JSON', async () => { + const photoCapture = new PhotoCapture() + photoCapture.presentation = 'dialog' + photoCapture.open = false + photoCapture.constraints = JSON.stringify({ video: true, audio: false }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + + const trigger = photoCapture.shadowRoot?.querySelector('button.trigger-button') as HTMLButtonElement + trigger.click() + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + expect(photoCapture.open).toBe(true) + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() + expect(getUserMedia).toHaveBeenCalledWith({ video: true, audio: false }) + }) + + it('dispatches a photo-captured event with the confirmed blob', async () => { + const photoCapture = new PhotoCapture() + const captured = jest.fn() + const changed = jest.fn() + + photoCapture.addEventListener('photo-captured', (event: Event) => { + captured((event as CustomEvent).detail) + }) + photoCapture.addEventListener('change', (event: Event) => { + changed((event as CustomEvent).detail) + }) + + document.body.appendChild(photoCapture) + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + + const confirmButton = photoCapture.shadowRoot?.querySelector('[part="confirm-button"]') as HTMLButtonElement + confirmButton.click() + + expect(captured).toHaveBeenCalledWith({ + file: expect.any(File), + blob: expect.any(Blob), + objectUrl: 'blob:test-photo', + contentType: 'image/png' + }) + expect(photoCapture.value).toBeInstanceOf(File) + expect(changed).toHaveBeenCalledWith({ value: photoCapture.value }) + }) + + it('can participate in a form-like submission while still exposing a value property', async () => { + const form = document.createElement('form') + const photoCapture = new PhotoCapture() + photoCapture.name = 'avatar' + form.appendChild(photoCapture) + document.body.appendChild(form) + + await photoCapture.updateComplete + await Promise.resolve() + await photoCapture.updateComplete + + const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement + Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) + Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) + + await (photoCapture as any)._captureSnapshot() + await photoCapture.updateComplete + ;(photoCapture as any)._confirmPhoto() + + expect(photoCapture.value).toBeInstanceOf(File) + + const formData = new FormData() + const formDataEvent = new Event('formdata') as Event & { formData: FormData } + formDataEvent.formData = formData + form.dispatchEvent(formDataEvent) + + const submitted = formData.get('avatar') + expect(submitted).toBeInstanceOf(File) + expect((submitted as File).name).toContain('avatar-') + }) }) diff --git a/src/v2/components/photoCapture/PhotoCapture.ts b/src/v2/components/photoCapture/PhotoCapture.ts index a5103dd2f..c57b7dfb8 100644 --- a/src/v2/components/photoCapture/PhotoCapture.ts +++ b/src/v2/components/photoCapture/PhotoCapture.ts @@ -1,8 +1,8 @@ import { LitElement, css, html, nothing, type PropertyValues } from 'lit' /* The original code was written by Sir Tim Berners-Lee. It was made into a -web component by AI Model GPT-5.4 -Prompt: Take the code from src/media/media-capture.ts and make it a -web component. Make it work in forms as well as not. Make it +web component by AI Model GPT-5.4 +Prompt: Take the code from src/media/media-capture.ts and make it a +web component. Make it work in forms as well as not. Make it configurable and follow LoginButton. */ export interface PhotoCapturedDetail { file: File @@ -300,7 +300,7 @@ export class PhotoCapture extends LitElement { private readonly _handleFormReset = () => { this._clearValue({ emitEvents: false }) if (this.open) { - void this._startPreview() + this._queuePreviewStart() } } @@ -426,7 +426,7 @@ export class PhotoCapture extends LitElement { !this._startingPreview && (changed.has('open') || changed.has('presentation') || changed.has('_previewUrl') || changed.has('value')) ) { - void this._startPreview() + this._queuePreviewStart() } if (changed.has('name') || changed.has('disabled') || changed.has('value')) { @@ -549,6 +549,10 @@ export class PhotoCapture extends LitElement { return new File([blob], `${safePrefix}-${Date.now()}.${extension}`, { type: contentType }) } + private _queuePreviewStart () { + this._startPreview().catch(() => undefined) + } + private _resolveMediaConstraints (): MediaStreamConstraints { if (this.mediaConstraints) { return this.mediaConstraints From 5c2b0fa33788732803034bdbf9282f3c0da14855 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 1 May 2026 14:11:44 +1000 Subject: [PATCH 21/36] refactor: add manifest-driven v2 component build and export sync --- README.md | 20 ++++++++++-- package.json | 5 +-- scripts/component-manifest.mjs | 50 ++++++++++++++++++++++++++++++ scripts/sync-component-exports.mjs | 17 ++++++++++ webpack.config.mjs | 23 ++------------ 5 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 scripts/component-manifest.mjs create mode 100644 scripts/sync-component-exports.mjs diff --git a/README.md b/README.md index 41b1d176e..cbef9bf3f 100644 --- a/README.md +++ b/README.md @@ -322,9 +322,13 @@ import { SignupButton } from 'solid-ui/components/signup-button' Web components use a two-stage build to produce a clean public runtime layout while keeping internal TypeScript artifacts separate: -1. **webpack** (`npm run build-dist`) bundles each component entrypoint and emits the runtime files to `dist/components//index.js` and `dist/components//index.esm.js`. -2. **tsc** (`npm run build-js`) emits internal declaration and JS artifacts mirroring the source tree under `dist/v2/components//`. -3. **`scripts/build-component-dts.mjs`** (runs automatically after tsc as part of `postbuild-js`) writes thin public declaration wrappers at `dist/components//index.d.ts`, re-exporting from the internal `dist/v2/components//` output. +1. **`scripts/component-manifest.mjs`** is the source of truth for v2 web components. It defines the component entrypoints used by webpack and the public subpath names exposed from the package. +2. **webpack** (`npm run build-dist`) bundles each component entrypoint from the manifest and emits the runtime files to `dist/components//index.js` and `dist/components//index.esm.js`. +3. **tsc** (`npm run build-js`) emits internal declaration and JS artifacts mirroring the source tree under `dist/v2/components//`. +4. **`scripts/build-component-dts.mjs`** (runs automatically after tsc as part of `postbuild-js`) writes thin public declaration wrappers at `dist/components//index.d.ts`, re-exporting from the internal `dist/v2/components//` output. +5. **`scripts/sync-component-exports.mjs`** keeps the `package.json` `exports` map aligned with the manifest. It runs automatically as part of `npm run build` and `npm version` workflows. + +The legacy main bundle remains a special case. In [webpack.config.mjs](webpack.config.mjs) only the `main` entry keeps the UMD `UI` global export; component entries are generated from the manifest and built as standalone scripts so they do not clobber one another when loaded directly. This keeps the `package.json` subpath export fully aligned while exposing only the public `dist/components/...` layout: @@ -338,6 +342,16 @@ This keeps the `package.json` subpath export fully aligned while exposing only t Consumers never import from `dist/v2/components/...`; that path is an internal build artifact only. +### Adding a new web component + +When adding a new v2 component: + +1. Create the component folder under `src/v2/components//` with its `index.ts` entrypoint. +2. Add one record to `scripts/component-manifest.mjs`. +3. Run `npm run sync-component-exports` if you want to update `package.json` immediately, or just run `npm run build` and let the build do it automatically. + +You should not need to hand-edit the webpack component entry list or the `package.json` component export map anymore. + ## Development When developing a component in solid-ui you can test it in isolation using storybook diff --git a/package.json b/package.json index bc3a9e34e..108810968 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,11 @@ ], "scripts": { "clean": "rm -rf ./dist ./src/versionInfo.ts ./docs/api .tsbuildinfo", - "build": "npm run clean && npm run typecheck && npm run build-version && npm run build-dist && npm run build-js && npm run postbuild-js && npm run build-storybook", + "build": "npm run clean && npm run sync-component-exports && npm run typecheck && npm run build-version && npm run build-dist && npm run build-js && npm run postbuild-js && npm run build-storybook", "build-version": "sh ./timestamp.sh > src/versionInfo.ts && eslint 'src/versionInfo.ts' --fix", "prebuild-js": "rm -f .tsbuildinfo", "build-js": "tsc", + "sync-component-exports": "node scripts/sync-component-exports.mjs", "postbuild-js": "rm -f dist/versionInfo.d.ts dist/versionInfo.d.ts.map && node scripts/build-component-dts.mjs", "build-dist": "webpack --progress", "build-form-examples": "npm run build-js && npm run build-version && npm run build-dist && cp ./dist/solid-ui.js ./docs/form-examples/", @@ -74,7 +75,7 @@ "dev": "npm run build-version && sh -c 'npm run watch:js & npm run watch:component-dts & npm run watch:dist & wait'", "doc": "typedoc --out ./docs/api/ ./src/ --excludeInternal", "prepublishOnly": "npm run build && npm run lint && npm test && npm run doc", - "preversion": "npm run lint && npm run typecheck && npm test", + "preversion": "npm run sync-component-exports && npm run lint && npm run typecheck && npm test", "postpublish": "git push origin main --follow-tags", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build --output-dir ./examples/storybook" diff --git a/scripts/component-manifest.mjs b/scripts/component-manifest.mjs new file mode 100644 index 000000000..ce3f7dfed --- /dev/null +++ b/scripts/component-manifest.mjs @@ -0,0 +1,50 @@ +export const v2Components = [ + { + sourceDir: 'header', + exportName: 'header' + }, + { + sourceDir: 'loginButton', + exportName: 'login-button' + }, + { + sourceDir: 'signupButton', + exportName: 'signup-button' + }, + { + sourceDir: 'photoCapture', + exportName: 'photo-capture' + }, + { + sourceDir: 'footer', + exportName: 'footer' + }, + { + sourceDir: 'select', + exportName: 'select' + }, + { + sourceDir: 'combobox', + exportName: 'combobox' + } +] + +export const componentEntries = Object.fromEntries( + v2Components.map(({ sourceDir }) => [ + sourceDir, + { + import: `./src/v2/components/${sourceDir}/index.ts` + } + ]) +) + +export const componentExports = Object.fromEntries( + v2Components.map(({ sourceDir, exportName }) => [ + `./components/${exportName}`, + { + types: `./dist/components/${sourceDir}/index.d.ts`, + import: `./dist/components/${sourceDir}/index.esm.js`, + require: `./dist/components/${sourceDir}/index.js` + } + ]) +) \ No newline at end of file diff --git a/scripts/sync-component-exports.mjs b/scripts/sync-component-exports.mjs new file mode 100644 index 000000000..814459250 --- /dev/null +++ b/scripts/sync-component-exports.mjs @@ -0,0 +1,17 @@ +import { readFileSync, writeFileSync } from 'fs' +import path from 'path' +import { componentExports } from './component-manifest.mjs' + +const packageJsonPath = path.resolve(process.cwd(), 'package.json') +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + +const preservedExports = Object.fromEntries( + Object.entries(packageJson.exports || {}).filter(([subpath]) => !subpath.startsWith('./components/')) +) + +packageJson.exports = { + ...preservedExports, + ...componentExports +} + +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) \ No newline at end of file diff --git a/webpack.config.mjs b/webpack.config.mjs index 73904b76f..fb61a25c8 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -1,5 +1,6 @@ import path from 'path' import TerserPlugin from 'terser-webpack-plugin' +import { componentEntries } from './scripts/component-manifest.mjs' const externalsBase = { fs: 'null', @@ -31,27 +32,7 @@ const common = { type: 'umd' } }, - header: { - import: './src/v2/components/header/index.ts' - }, - loginButton: { - import: './src/v2/components/loginButton/index.ts' - }, - signupButton: { - import: './src/v2/components/signupButton/index.ts' - }, - photoCapture: { - import: './src/v2/components/photoCapture/index.ts' - }, - footer: { - import: './src/v2/components/footer/index.ts' - }, - select: { - import: './src/v2/components/select/index.ts' - }, - combobox: { - import: './src/v2/components/combobox/index.ts' - } + ...componentEntries }, output: { path: path.resolve(process.cwd(), 'dist'), From 0da9a83998c3097d038fcaf4fc8c76a8b50695d4 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 2 May 2026 06:06:35 +1000 Subject: [PATCH 22/36] add button web comp --- src/v2/components/button/Button.test.ts | 122 ++++++++++ src/v2/components/button/Button.ts | 306 ++++++++++++++++++++++++ src/v2/components/button/README.md | 154 ++++++++++++ 3 files changed, 582 insertions(+) create mode 100644 src/v2/components/button/Button.test.ts create mode 100644 src/v2/components/button/Button.ts create mode 100644 src/v2/components/button/README.md diff --git a/src/v2/components/button/Button.test.ts b/src/v2/components/button/Button.test.ts new file mode 100644 index 000000000..2617f3aec --- /dev/null +++ b/src/v2/components/button/Button.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { Button } from './Button' +import './index' + +describe('SolidUIButton', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-button')).toBe(Button) + }) + + it('renders a secondary button by default', async () => { + const button = new Button() + button.label = 'Upload' + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector('button') as HTMLButtonElement + + expect(button.variant).toBe('secondary') + expect(nativeButton.type).toBe('button') + expect(nativeButton.textContent?.trim()).toBe('Upload') + }) + + it('supports a selected state without forcing toggle semantics', async () => { + const button = new Button() + button.selected = true + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector('button') as HTMLButtonElement + + expect(button.hasAttribute('selected')).toBe(true) + expect(nativeButton.hasAttribute('aria-pressed')).toBe(false) + expect(nativeButton.hasAttribute('aria-selected')).toBe(false) + }) + + it('calls the callback property and still emits the native click event', async () => { + const button = new Button() + const handleClick = jest.fn() + const clickListener = jest.fn() + button.handleClick = handleClick + button.addEventListener('click', clickListener) + + document.body.appendChild(button) + await button.updateComplete + + const nativeButton = button.shadowRoot?.querySelector('button') as HTMLButtonElement + nativeButton.click() + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(clickListener).toHaveBeenCalledTimes(1) + }) + + it('renders an image icon when the icon property is provided', async () => { + const button = new Button() + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + + document.body.appendChild(button) + await button.updateComplete + + const icon = button.shadowRoot?.querySelector('.button__icon-image') as HTMLImageElement + expect(icon.getAttribute('src')).toBe(button.icon) + }) + + it('supports an icon-only variant without rendering the label text', async () => { + const button = new Button() + button.variant = 'icon' + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + button.label = 'Settings' + + document.body.appendChild(button) + await button.updateComplete + + const label = button.shadowRoot?.querySelector('.button__label') as HTMLSpanElement + const icon = button.shadowRoot?.querySelector('.button__icon-image') as HTMLImageElement + + expect(button.variant).toBe('icon') + expect(label).not.toBeNull() + expect(icon.getAttribute('src')).toBe(button.icon) + }) + + it('prefers slotted icon content over the icon property fallback', async () => { + const button = document.createElement('solid-ui-button') as Button + button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' + + const slottedIcon = document.createElement('span') + slottedIcon.slot = 'icon' + slottedIcon.textContent = 'icon' + button.appendChild(slottedIcon) + + document.body.appendChild(button) + await button.updateComplete + await Promise.resolve() + await button.updateComplete + + expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() + }) + + it('renders slotted icon content without requiring an icon fallback property', async () => { + const button = document.createElement('solid-ui-button') as Button + + const slottedIcon = document.createElement('span') + slottedIcon.slot = 'icon' + slottedIcon.textContent = 'icon' + button.appendChild(slottedIcon) + + document.body.appendChild(button) + await button.updateComplete + await Promise.resolve() + await button.updateComplete + + expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon')).not.toBeNull() + expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() + }) +}) diff --git a/src/v2/components/button/Button.ts b/src/v2/components/button/Button.ts new file mode 100644 index 000000000..125ff8dc6 --- /dev/null +++ b/src/v2/components/button/Button.ts @@ -0,0 +1,306 @@ +import { LitElement, html, css, nothing } from 'lit' + +export class Button extends LitElement { + static properties = { + label: { type: String, reflect: true }, + type: { type: String, reflect: true }, + disabled: { type: Boolean, reflect: true }, + selected: { type: Boolean, reflect: true }, + ariaLabel: { type: String, attribute: 'aria-label' }, + name: { type: String, reflect: true }, + value: { type: String, reflect: true }, + variant: { type: String, reflect: true }, + size: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + fullWidth: { type: Boolean, attribute: 'full-width', reflect: true }, + icon: { type: String, reflect: true }, + iconPosition: { type: String, attribute: 'icon-position', reflect: true }, + handleClick: { attribute: false }, + _hasSlottedIcon: { state: true } + } + + static styles = css` + :host { + display: inline-flex; + align-items: center; + justify-content: center; + --button-background: var(--color-background, #f8f9fb); + --button-text: var(--color-text-subheading, #101828); + --button-border: var(--color-border-button, var(--gray-300, #D1D5DC)); + --button-hover-background: var(--color-surface-subtle, rgba(15, 23, 43, 0.04)); + --button-hover-border: var(--color-border-button-hover, var(--gray-400, #99A1AF)); + --button-hover-text: var(--color-text-subheading, #101828); + --button-selected-background: var(--color-surface-selected, var(--color-surface-action, var(--color-primary, #7C4DFF))); + --button-selected-text: var(--color-text-selected, var(--color-text-on-action, var(--white, #FFF))); + --button-selected-border: var(--color-border-selected, var(--color-border-action, var(--color-primary, #7C4DFF))); + --button-icon-color: currentColor; + --button-focus-ring: var(--color-focus-ring, var(--color-primary, #7C4DFF)); + --button-height-sm: 1.875rem; + --button-height-md: var(--min-touch-target, 44px); + --button-height-lg: calc(var(--min-touch-target, 44px) + 0.5rem); + --button-padding-x-sm: var(--spacing-xs, 0.75rem); + --button-padding-x-md: var(--spacing-sm, 0.9375rem); + --button-padding-x-lg: var(--spacing-md, 1.25rem); + --button-font-size-sm: var(--font-size-sm, 0.875rem); + --button-font-size-md: var(--font-size-md, 1rem); + --button-font-size-lg: var(--font-size-lg, 1.125rem); + --button-icon-size-sm: var(--icon-xxxs, 0.75rem); + --button-icon-size-md: var(--icon-xxs, 1rem); + --button-icon-size-lg: var(--icon-xxs, 1rem); + } + + :host([theme='dark']) { + --button-background: var(--color-background, #242a31); + --button-text: var(--color-text-subheading, #f8f9fb); + --button-border: var(--color-border, #46515b); + --button-hover-background: var(--color-surface-subtle, rgba(15, 23, 43, 0.04)); + --button-hover-border: var(--color-border, #46515b); + --button-hover-text: var(--color-text-subheading, #f8f9fb); + } + + :host([variant='primary']) { + --button-background: var(--color-surface-action, var(--color-primary, #7C4DFF)); + --button-text: var(--color-text-on-action, var(--white, #FFF)); + --button-border: var(--color-border-action, var(--color-primary, #7C4DFF)); + --button-hover-background: var(--color-surface-action-hover, #6d3cf2); + --button-hover-border: var(--color-border-action, var(--color-primary, #7C4DFF)); + --button-hover-text: var(--color-text-on-action, var(--white, #FFF)); + } + + :host([variant='icon']) { + --button-padding-x-sm: var(--spacing-xxs, 0.3125rem); + --button-padding-x-md: var(--spacing-base, 0.5rem); + --button-padding-x-lg: var(--spacing-2xs, 0.625rem); + } + + :host([full-width]) { + width: 100%; + } + + :host([selected]) { + --button-background: var(--button-selected-background); + --button-text: var(--button-selected-text); + --button-border: var(--button-selected-border); + --button-hover-background: var(--button-selected-background); + --button-hover-border: var(--button-selected-border); + --button-hover-text: var(--button-selected-text); + } + + .button { + display: inline-flex; + width: 100%; + min-height: var(--button-height-md); + padding: 0 var(--button-padding-x-md); + align-items: center; + justify-content: center; + gap: var(--spacing-xxs, 0.375rem); + border-radius: var(--border-radius-base, 0.3125rem); + background: var(--button-background); + border: 1px solid var(--button-border); + color: var(--button-text); + cursor: pointer; + font: inherit; + font-size: var(--button-font-size-md); + font-weight: var(--font-weight-bold, 600); + line-height: 1; + white-space: nowrap; + text-decoration: none; + box-sizing: border-box; + transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; + } + + :host([size='sm']) .button { + min-height: var(--button-height-sm); + padding: 0 var(--button-padding-x-sm); + font-size: var(--button-font-size-sm); + } + + :host([size='lg']) .button { + min-height: var(--button-height-lg); + padding: 0 var(--button-padding-x-lg); + font-size: var(--button-font-size-lg); + } + + .button:hover:not(:disabled) { + background: var(--button-hover-background); + border-color: var(--button-hover-border, var(--button-border)); + color: var(--button-hover-text); + } + + .button:focus-visible { + outline: 2px solid var(--button-focus-ring); + outline-offset: 2px; + } + + .button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .button__content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: inherit; + width: 100%; + } + + :host([icon-position='end']) .button__content { + flex-direction: row-reverse; + } + + .button__icon { + width: var(--button-icon-size-md); + height: var(--button-icon-size-md); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--button-icon-color); + flex-shrink: 0; + } + + :host([size='sm']) .button__icon { + width: var(--button-icon-size-sm); + height: var(--button-icon-size-sm); + } + + :host([size='lg']) .button__icon { + width: var(--button-icon-size-lg); + height: var(--button-icon-size-lg); + } + + .button__icon ::slotted(*) { + width: 100%; + height: 100%; + display: block; + } + + .button__icon-image { + width: 100%; + height: 100%; + object-fit: contain; + } + + .button__label { + display: inline-flex; + align-items: center; + min-width: 0; + } + + :host([variant='icon']) .button__label { + display: none; + } + + .button:active { + transform: translateY(1px); + } + ` + + declare label: string + declare type: 'button' | 'submit' | 'reset' + declare disabled: boolean + declare selected: boolean + declare ariaLabel: string + declare name: string + declare value: string + declare variant: 'primary' | 'secondary' | 'icon' + declare size: 'sm' | 'md' | 'lg' + declare theme: 'light' | 'dark' + declare fullWidth: boolean + declare icon: string + declare iconPosition: 'start' | 'end' + declare handleClick?: (event: MouseEvent) => void + declare _hasSlottedIcon: boolean + private _iconSlotObserver?: MutationObserver + + constructor () { + super() + this.label = '' + this.type = 'button' + this.disabled = false + this.selected = false + this.ariaLabel = '' + this.name = '' + this.value = '' + this.variant = 'secondary' + this.size = 'md' + this.theme = 'light' + this.fullWidth = false + this.icon = '' + this.iconPosition = 'start' + this.handleClick = undefined + this._hasSlottedIcon = false + } + + connectedCallback () { + super.connectedCallback() + this._syncSlottedIconPresence() + + this._iconSlotObserver = new MutationObserver(() => { + this._syncSlottedIconPresence() + }) + + this._iconSlotObserver.observe(this, { + childList: true, + attributes: true, + attributeFilter: ['slot'] + }) + } + + disconnectedCallback () { + this._iconSlotObserver?.disconnect() + this._iconSlotObserver = undefined + super.disconnectedCallback() + } + + private _handleButtonClick (event: MouseEvent) { + this.handleClick?.(event) + } + + private _handleIconSlotChange (event: Event) { + const slot = event.target as HTMLSlotElement + this._hasSlottedIcon = slot.assignedNodes({ flatten: true }).length > 0 + } + + private _syncSlottedIconPresence () { + this._hasSlottedIcon = this.querySelector('[slot="icon"]') !== null + } + + private _renderIcon () { + if (!this._hasSlottedIcon && !this.icon) { + return nothing + } + + return html` + + + ${!this._hasSlottedIcon && this.icon + ? html`` + : nothing} + + ` + } + + render () { + return html` + + ` + } +} diff --git a/src/v2/components/button/README.md b/src/v2/components/button/README.md new file mode 100644 index 000000000..b67a46d90 --- /dev/null +++ b/src/v2/components/button/README.md @@ -0,0 +1,154 @@ +# solid-ui-button component + +A Lit-based base button component for shared actions across panes and apps. It stays semantic by rendering a native ` + ` + } +} diff --git a/src/v2/components/actions/button/README.md b/src/v2/components/actions/button/README.md new file mode 100644 index 000000000..31028df67 --- /dev/null +++ b/src/v2/components/actions/button/README.md @@ -0,0 +1,156 @@ +# solid-ui-button component + +A Lit-based base button component for shared actions across panes and apps. It stays semantic by rendering a native ` - ` - } -} diff --git a/src/v2/components/button/README.md b/src/v2/components/button/README.md deleted file mode 100644 index b67a46d90..000000000 --- a/src/v2/components/button/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# solid-ui-button component - -A Lit-based base button component for shared actions across panes and apps. It stays semantic by rendering a native `
      Date: Tue, 5 May 2026 07:45:08 +1000 Subject: [PATCH 29/36] make bullseye drop friends work a little better --- src/widgets/dragAndDrop.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/widgets/dragAndDrop.js b/src/widgets/dragAndDrop.js index 38fc00cf9..376ae246e 100644 --- a/src/widgets/dragAndDrop.js +++ b/src/widgets/dragAndDrop.js @@ -15,12 +15,22 @@ import { style } from '../style' /* global FileReader alert */ export function makeDropTarget (ele, droppedURIHandler, droppedFileHandler) { + const normalizeDroppedUris = function (uriText) { + return uriText + .split('\n') + .map(uri => uri.trim()) + .filter(uri => uri && uri[0] !== '#') + } + const dragoverListener = function (e) { e.preventDefault() // Need this; otherwise, drop does not work. + e.stopPropagation() e.dataTransfer.dropEffect = 'copy' } const dragenterListener = function (e) { + e.preventDefault() + e.stopPropagation() debug.log('dragenter event dropEffect: ' + e.dataTransfer.dropEffect) if (this.localStyle) { // necessary not sure when @@ -33,6 +43,7 @@ export function makeDropTarget (ele, droppedURIHandler, droppedFileHandler) { debug.log('dragenter event dropEffect 2: ' + e.dataTransfer.dropEffect) } const dragleaveListener = function (e) { + e.stopPropagation() debug.log('dragleave event dropEffect: ' + e.dataTransfer.dropEffect) if (this.savedStyle) { this.localStyle = this.savedStyle @@ -43,6 +54,7 @@ export function makeDropTarget (ele, droppedURIHandler, droppedFileHandler) { const dropListener = function (e) { if (e.preventDefault) e.preventDefault() // stops the browser from redirecting off to the text. + if (e.stopPropagation) e.stopPropagation() debug.log('Drop event. dropEffect: ' + e.dataTransfer.dropEffect) debug.log( 'Drop event. types: ' + @@ -55,7 +67,7 @@ export function makeDropTarget (ele, droppedURIHandler, droppedFileHandler) { for (let t = 0; t < e.dataTransfer.types.length; t++) { const type = e.dataTransfer.types[t] if (type === 'text/uri-list') { - uris = e.dataTransfer.getData(type).split('\n') // @ ignore those starting with # + uris = normalizeDroppedUris(e.dataTransfer.getData(type)) debug.log('Dropped text/uri-list: ' + uris) } else if (type === 'text/plain') { text = e.dataTransfer.getData(type) @@ -79,13 +91,14 @@ export function makeDropTarget (ele, droppedURIHandler, droppedFileHandler) { droppedFileHandler(files) } } - if (uris === null && text && text.slice(0, 4) === 'http') { - uris = text + const trimmedText = text ? text.trim() : '' + if (uris === null && trimmedText && trimmedText.slice(0, 4) === 'http') { + uris = [trimmedText] debug.log('Waring: Poor man\'s drop: using text for URI') // chrome disables text/uri-list?? } } else { // ... however, if we're IE, we don't have the .types property, so we'll just get the Text value - uris = [e.dataTransfer.getData('Text')] + uris = normalizeDroppedUris(e.dataTransfer.getData('Text')) debug.log('WARNING non-standard drop event: ' + uris[0]) } debug.log('Dropped URI list (2): ' + uris) From 5ba5ac551cf6bec4adf46ab40edf0e6a14234e87 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 07:45:53 +1000 Subject: [PATCH 30/36] button type for using in js --- src/v2/components/actions/button/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/v2/components/actions/button/index.ts b/src/v2/components/actions/button/index.ts index 36ddb12a7..3904c59e2 100644 --- a/src/v2/components/actions/button/index.ts +++ b/src/v2/components/actions/button/index.ts @@ -4,6 +4,12 @@ export { Button } const BUTTON_TAG_NAME = 'solid-ui-button' +declare global { + interface HTMLElementTagNameMap { + 'solid-ui-button': Button + } +} + if (!customElements.get(BUTTON_TAG_NAME)) { customElements.define(BUTTON_TAG_NAME, Button) } From 040da0a7ed3ea602a02541f86d75f84dabb82a82 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 07:51:32 +1000 Subject: [PATCH 31/36] dragdrop button improvement --- src/widgets/buttons.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/widgets/buttons.ts b/src/widgets/buttons.ts index 392833c19..41ac758c5 100644 --- a/src/widgets/buttons.ts +++ b/src/widgets/buttons.ts @@ -1028,6 +1028,10 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML attachmentLeft.appendChild(paperclip) const fhandler = options.uploadFolder ? droppedFileHandler : null makeDropTarget(paperclip, droppedURIHandler, fhandler) // beware missing the wire of the paparclip! + const paperclipImage = paperclip.querySelector('img') + if (paperclipImage) { + makeDropTarget(paperclipImage, droppedURIHandler, fhandler) + } makeDropTarget(attachmentLeft, droppedURIHandler, fhandler) // just the outer won't do it if (options.uploadFolder) { // Addd an explicit file upload button as well From cf94f33bc1eb9afc2b983ab672634033bc25663d Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 10:14:52 +1000 Subject: [PATCH 32/36] test lint error --- test/unit/widgets/forms/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/widgets/forms/index.test.ts b/test/unit/widgets/forms/index.test.ts index 93bc1f665..df4a59b39 100644 --- a/test/unit/widgets/forms/index.test.ts +++ b/test/unit/widgets/forms/index.test.ts @@ -609,7 +609,7 @@ describe('buildCheckboxForm', () => { const updateSpy = jest.fn((_deletes, _inserts, callback) => { return new Promise(resolve => { setTimeout(() => { - callback('uri', true, 'ok') + callback(undefined, true, 'ok') resolve(true) }, 0) }) From cb84838a83cbb6a09fb7f54ec90cc8526ce0bfcf Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 10:16:01 +1000 Subject: [PATCH 33/36] lint error in login --- src/login/login.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index f69f82066..1e857ba2b 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1047,11 +1047,11 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { - const sessionInfo = authSession.info + const sessionInfo = authSession.info if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { return [] } - + const currentUser = authn.currentUser() if (!currentUser) { return [] From f49914184a50af1e769afe41b0f93bb180e62a73 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 13:40:01 +1000 Subject: [PATCH 34/36] Make login status background optional --- src/login/login.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/login/login.ts b/src/login/login.ts index 1e857ba2b..28ee1e2ca 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -758,7 +758,7 @@ export function selectWorkspace ( const box = dom.createElement('div') const context: AuthenticationContext = { me, dom, div: box } - function say (s, background) { + function say (s, background?) { box.appendChild(widgets.errorMessageBlock(dom, s, background)) } From 340f7c829f6180e7034815bb72a8b583aa6c6094 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 13:40:01 +1000 Subject: [PATCH 35/36] Prevent early account label truncation --- src/v2/components/layout/header/Header.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index e6ccb1c39..44f129b76 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -355,11 +355,14 @@ export class Header extends LitElement { .account-menu-copy { display: flex; flex-direction: column; + flex: 1 1 auto; min-width: 0; } .account-menu-label { color: var(--header-button-text); + display: block; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; From 1f9713b0a0b07fad9cf2a024437d2d69730fb153 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 20:35:03 +1000 Subject: [PATCH 36/36] revert login --- src/login/login.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index 1e857ba2b..b93e07f11 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1047,20 +1047,10 @@ export function newAppInstance ( * and/or a developer */ export async function getUserRoles (): Promise> { - const sessionInfo = authSession.info - if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { - return [] - } - - const currentUser = authn.currentUser() - if (!currentUser) { - return [] - } - try { - const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ me: currentUser }) + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({}) if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError || 'Unable to load user preferences file.') + throw new Error(preferencesFileError) } return solidLogicSingleton.store.each( me,