diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 112013c..a540e4c 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -58,6 +58,7 @@ none of these frontend controls has passed a worn-headset check yet. | `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test | | Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test | | `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test | +| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified | | Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions | | `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. | | `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 223b6d8..7239ae4 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -38,7 +38,8 @@ Decky websocket, because Decky allows only one frontend connection. The iframe does not receive Frame Control's UI key. React and ReactDOM are bundled for offline use under their MIT licenses in `ui/vendor`. -The host currently supplies sections, rows, buttons, toggles, dropdowns, +The host currently supplies sections, rows, buttons, toggles, text fields, +sliders, grouped dropdowns, scroll containers, a modal, locale information, local panel routes and toasts, and backend calls for the plugin that owns the panel. Steam library/store page patches, global Steam UI components, file pickers, and privileged root methods diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index d8aad62..3473ab7 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -28,25 +28,45 @@ assert.deepEqual(jsx.children,['Saved']); const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']}); assert.equal(fragments.type,React.Fragment); assert.deepEqual(fragments.children,[['one','two']]); -const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))]; -// Official Decky controls use disabled while a backend call is in progress. -// A disabled control must stay inert even if an event handler is invoked. +const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,type)): + [...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))]; +// Disabled controls stay inert even if a caller invokes their handlers directly. let changed=0; const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true, onChange:()=>changed++}),'input')[0]; -const dropdown=nodes(window.DFL.DropdownItem({label:'Mode',disabled:true,rgOptions:[{data:'a',label:'A'}], +const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}]; +const dropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',disabled:true, onChange:()=>changed++}),'select')[0]; const button=nodes(window.DFL.ButtonItem({label:'Reload',description:'Read setting',disabled:true, onClick:()=>changed++}),'button')[0]; -for(const control of [toggle,dropdown,button])assert.equal(control.props.disabled,true); +const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'New',disabled:true, + onChange:()=>changed++}),'input')[0]; +const slider=nodes(window.DFL.SliderField({label:'Speed',value:2,min:0,max:10,step:2,disabled:true, + onChange:()=>changed++}),'input')[0]; +for(const control of [toggle,dropdown,button,input,slider])assert.equal(control.props.disabled,true); +assert.equal(input.props.value,'Old'); +assert.equal(slider.props.type,'range'); +assert.equal(dropdown.props.value,'1'); +assert.equal(nodes(dropdown,'optgroup').length,1); toggle.props.onChange({target:{checked:false}}); -dropdown.props.onChange({target:{value:'a'}}); +dropdown.props.onChange({target:{value:'0'}}); button.props.onClick({}); +input.props.onChange({target:{value:'New'}}); +slider.props.onChange({target:{value:'4'}}); assert.equal(changed,0); +let entered=''; +const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0]; +readyInput.props.onChange({target:{value:'New'}});assert.equal(entered,'New'); +let speed=0; +const readySlider=nodes(window.DFL.SliderField({label:'Speed',value:2,onChange:v=>{speed=v;}}),'input')[0]; +readySlider.props.onChange({target:{value:'4'}});assert.equal(speed,4); +let selected; +const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast', + onChange:option=>{selected=option;}}),'select')[0]; +readyDropdown.props.onChange({target:{value:'0'}});assert.equal(selected.data,'slow'); const readyButton=nodes(window.DFL.ButtonItem({label:'Reload',onClick:()=>changed++}),'button')[0]; assert.equal(readyButton.props.disabled,false); -readyButton.props.onClick({}); -assert.equal(changed,1); +readyButton.props.onClick({});assert.equal(changed,1); let called=0; const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'}, frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}}, diff --git a/ui/framey-host.js b/ui/framey-host.js index eddd933..c94c015 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -49,13 +49,35 @@ const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description}, R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e.target.checked);}})); - const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, onChange}) => R.createElement(Field, {label, description}, - R.createElement('select', {value:String(selectedOption ?? ''), disabled:!!disabled, - onChange:e=>{if(!disabled)onChange?.(rgOptions.find(x=>String(x.data)===e.target.value));}}, - rgOptions.map(x=>R.createElement('option', {key:String(x.data), value:String(x.data)}, x.label)))); + const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, strDefaultLabel, onChange}) => { + const choices=[]; + const option = entry => { + const index=choices.push(entry)-1; + return R.createElement('option', {key:index, value:String(index)}, entry.label); + }; + const entries=(Array.isArray(rgOptions) ? rgOptions : []).map(entry => + Array.isArray(entry.options) ? R.createElement('optgroup', {key:String(entry.label), label:String(entry.label)}, + entry.options.map(option)) : option(entry)); + const selected=choices.findIndex(entry=>Object.is(entry.data, selectedOption)); + return R.createElement(Field, {label, description}, R.createElement('select', { + value:selected < 0 ? '' : String(selected), disabled:!!disabled, + onChange:e=>{const entry=choices[Number(e.target.value)];if(!disabled && entry)onChange?.(entry);}}, + selected < 0 && R.createElement('option', {value:'', disabled:true}, strDefaultLabel || 'Choose an option'), + entries)); + }; const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description}, R.createElement('button', {type:'button', disabled:!!disabled, onClick:e=>{if(!disabled)onClick?.(e);}}, children || label)); + const TextField = ({label, description, value, placeholder, disabled, bIsPassword, + mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description}, + R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text', + value, placeholder, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e);}})); + const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue, + valueSuffix='', onChange}) => R.createElement(Field, {label, description}, + R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled, + 'aria-label':typeof label==='string'?label:undefined, + onChange:e=>{if(!disabled)onChange?.(Number(e.target.value));}}), + showValue && R.createElement('span', null, String(value) + valueSuffix)); const PanelSection = ({title, children}) => R.createElement('section', null, title && R.createElement('h2', null, title), children); const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null, @@ -83,7 +105,8 @@ }; const DFL = window.DFL = { definePlugin: init => init, - PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem, + PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, + Dropdown: DropdownItem, ButtonItem, TextField, SliderField, Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), ConfirmModal, staticClasses: {Title:'framey-title'},