Open UIによるWeb UI標準化への
アプローチ

ゆめみ×LayerX×サイボウズ3社合同フロントエンドカンファレンス北海道2024後夜祭@東京
saku🌸 / @sakupi01

saku

Web Frontend Engineer
@Cybozu

24卒/🍏/ ☕/ 🌏 > ❤️
𝕏 @sakupi01

ブラウザネイティブUIの進化へ:

Open UIによるWeb UI標準化への
アプローチ

Available in EN

1. Open UIとは

Open UIの概要

  • W3C Community Groupの一つ
  • 一般的なUIパターンを体系化して汎用的なUIコンポーネントやコントロールを提案、その技術を標準化する提案に取り組んでいる
  • 独自UIを作るにはどうHTML, CSS, JS, Web APIを組み合わせるのが適切なのか決める
    • とはいえ、標準そのものの決定はしないと言ってる
  • 標準を決める具体的なグループ(WHATWG, CSSWG, TC39などなど)向けの推奨事項を作成する
  • 策定された仕様に基づいた内容を各ブラウザに実装(してもらう)

https://open-ui.org/

Open UI発足の背景

  • WHATWGが、多くのWebサイトで見られる最も一般的なパターンをHTML自体で体系化する取り組みに着手(Web 2.0。2004年頃から)
    • その一環で、Web開発者がHTMLを使用するだけでリッチなformを実装できるように
  • その最後の大幅改定後、昨今のWebアプリケーションはより多くの複雑なIFを提供するように
  • 現代のWebアプリケーションに適合したWeb UIの一般的なパターンを提供するために必要な技術を新たに標準化しよう!

Open UIがやること

  • 標準Webコンポーネント・コントロールの仕様検討&提案
  • WHATWGやW3Cなどの標準化団体と協力し、HTML、ARIA、CSSなどに実際に仕様を追加
  • Chromium、WebKit、Geckoなどのブラウザエンジンチームと協力し、仕様に基づく機能をブラウザエンジンに実装

Open UIにおけるStages

Proposalの策定段階を5つのStageに分けている(Open UI Working Mode

Stage 目的 入口基準 出口基準 Popover APIでの例
0: Research 調査内容を共有し、アイデアを統合できる状態にする Proposalが出され、Issueが開かれている Champion(そのテーマの責任者)が決まり、歴史的経緯の研究調査とProposalが2人のOpen UI EditorまたはChairによってApproveされる Research: popup and similar top-layer UI #345
1: Editor's Draft Open UI内でコンポーネントのExplainerに合意し、確定させる Usecase、Structure、Property、Behaviorなどをカバーする初期Draftが作成されている 仕様がOpen UI EditorまたはChairによってレビューおよびApproveされる New Approach for Popup #455
2: Community Draft ステークホルダー(WHATWG, ARIA, CSSWG, 他開発者など諸関係者)からのレビューを受け、フィードバックを取り入れる Open UIがEditor's Draftを外部グループや個人からの追加レビューのためにApproveしている ARIA、I18n、プライバシー、WHATWG、CSSWG、ブラウザ実装者、他Web開発者、Library Authorなどのステークホルダーからの承認 New feature proposal: Popover API #7785
3: Recommendation Proposalを最終状態に導く Championがステークホルダーと合意を形成している Web Platformに追加するかどうかの決定。Webコンポーネントが実装されたり、仕様が標準化団体に渡ったりする。適合度テストが行われ、Specが作成される。実装されるがまだExperimentalな状態。 Add popover attribute #8221
4: Finished コンポーネントが実装されたことを示す コンポーネントが安定した実装または仕様を持っている N/A Popover API lands in Baseline

2. Open UIのこれまでの取り組み

実現された提案の例

e.g. Popover API(Baseline 2024 Newly available!

  • 任意の要素にpopover属性を付与することで宣言的に Popover できる
    • 任意の要素をTopLayer に表示し、 Light Dismiss で閉じることもできる
    • 開閉がJSのみならずpopovertargetで実現可能に
<button popovertarget="mypopover">Toggle Popover</button>
<div id="mypopover" popover>This is the popover content</div>

Popover API (Explainer)

実現されつつある提案の例

e.g. Customizable Select Element: <select>(Stage3)

  • 柔軟にカスタマイズ可能な<select>要素を提供予定

※ Chrome 129+ (Canary) の Experimental Web Platform features を有効にすることで利用可能(demo by @una

Customizable Select Element (Explainer)

実現されなかった提案の例

e.g. <skeleton>要素/skeleton ARIA role

  • Skeletonのコンポーネント化/ARIA roleの提案が存在していたが見送られた
    • お見送り理由①: SkeletonはUIパターンではなく、UXパターン
    • お見送り理由②: 多様なUI要素に適用可能であり、一般化されたUIとして定義するのに適さない
    • skeletonを表現するARIA roleの提案Skeleton Aria Role #169
    • お見送り理由③: 既存のARIA属性aria-busyaria-livearia-hiddenで十分に対応可能であり、新しいARIA roleを追加することで開発者やユーザーに学習負担を強いる[OPEN UI] Skeleton aria role proposal #1317

marked as stale - [Skeleton] Add component proposal #139

3. まとめ

  • Open UIは、標準Webコンポーネント/コントロールの仕様検討&提案を行っている
  • Popover APIやCustomizable Select ElementなどはOpen UIの提案によって実現された(つつある)新しいコントロールたち!
  • 独自のデザインシステムを構築するときにも役立つ内容の提案がたくさん!
  • これからのWeb UIを決定づける提案がまだまだたくさんあって、目が離せない✨

ご清聴ありがとうございました!

\n
\n
\n `.split(/\n\s*/).join(""),this.wrapper=null!==(t=this.shadowRoot.querySelector(`div[${e}]`))&&void 0!==t?t:void 0;const l=this.svg;this.svg=null!==(o=null===(s=this.wrapper)||void 0===s?void 0:s.querySelector(`svg[${i}]`))&&void 0!==o?o:void 0,this.svg!==l&&(this.svgComputedStyle=this.svg?window.getComputedStyle(this.svg):void 0),this.container=null!==(a=null===(r=this.svg)||void 0===r?void 0:r.querySelector(`span[${n}]`))&&void 0!==a?a:void 0,this.observe()}disconnectedCallback(){this.svg=void 0,this.svgComputedStyle=void 0,this.wrapper=void 0,this.container=void 0,this.observe()}attributeChangedCallback(){this.observe()}flushSvgDisplay(){const{svg:t}=this;t&&(t.style.display="inline",requestAnimationFrame((()=>{t.style.display=""})))}observe(){this.containerObserver.disconnect(),this.wrapperObserver.disconnect(),this.wrapper&&this.wrapperObserver.observe(this.wrapper),this.container&&this.containerObserver.observe(this.container),this.svgComputedStyle&&this.observeSVGStyle(this.svgComputedStyle)}observeSVGStyle(t){const e=()=>{const i=(()=>{const e=t.getPropertyValue("--preserve-aspect-ratio");if(e)return e.trim();return`x${(({textAlign:t,direction:e})=>{if(t.endsWith("left"))return"Min";if(t.endsWith("right"))return"Max";if("start"===t||"end"===t){let i="rtl"===e;return"end"===t&&(i=!i),i?"Max":"Min"}return"Mid"})(t)}YMid meet`})();i!==this.svgPreserveAspectRatio&&(this.svgPreserveAspectRatio=i,this.updateSVGRect()),t===this.svgComputedStyle&&requestAnimationFrame(e)};e()}updateSVGRect(){var t,e,i,n,s,o,r;let a=Math.ceil(null!==(e=null===(t=this.containerSize)||void 0===t?void 0:t.width)&&void 0!==e?e:0);const l=Math.ceil(null!==(n=null===(i=this.containerSize)||void 0===i?void 0:i.height)&&void 0!==n?n:0);void 0!==this.dataset.downscaleOnly&&(a=Math.max(a,null!==(o=null===(s=this.wrapperSize)||void 0===s?void 0:s.width)&&void 0!==o?o:0));const c=null===(r=this.svg)||void 0===r?void 0:r.querySelector(":scope > foreignObject");if(null==c||c.setAttribute("width",`${a}`),null==c||c.setAttribute("height",`${l}`),this.svg&&(this.svg.setAttribute("viewBox",`0 0 ${a} ${l}`),this.svg.setAttribute("preserveAspectRatio",this.svgPreserveAspectRatio),this.svg.style.height=a<=0||l<=0?"0":""),this.container){const t=this.svgPreserveAspectRatio.toLowerCase();this.container.style.marginLeft=t.startsWith("xmid")||t.startsWith("xmax")?"auto":"0",this.container.style.marginRight=t.startsWith("xmi")?"auto":"0"}}}const o=(t,{attrs:e={},style:i})=>class extends t{constructor(...t){super(...t);for(const[t,i]of Object.entries(e))this.hasAttribute(t)||this.setAttribute(t,i);this.attachShadow({mode:"open"})}static get observedAttributes(){return["data-auto-scaling"]}connectedCallback(){this._update()}attributeChangedCallback(){this._update()}_update(){const t=i?``:"";let e="";const{autoScaling:n}=this.dataset;if(void 0!==n){e=`${e}`}this.shadowRoot.innerHTML=t+e}};let r;const a=Symbol();let l;const c="marpitSVGPolyfill:setZoomFactor,",d=Symbol(),g=Symbol();const h=()=>{const t="Apple Computer, Inc."===navigator.vendor,e=t?[u]:[],i={then:e=>(t?(async()=>{if(void 0===l){const t=document.createElement("canvas");t.width=10,t.height=10;const e=t.getContext("2d"),i=new Image(10,10),n=new Promise((t=>{i.addEventListener("load",(()=>t()))}));i.crossOrigin="anonymous",i.src="data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%20viewBox%3D%220%200%201%201%22%3E%3CforeignObject%20width%3D%221%22%20height%3D%221%22%20requiredExtensions%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3E%3Cdiv%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%20style%3D%22width%3A%201px%3B%20height%3A%201px%3B%20background%3A%20red%3B%20position%3A%20relative%22%3E%3C%2Fdiv%3E%3C%2FforeignObject%3E%3C%2Fsvg%3E",await n,e.drawImage(i,0,0),l=e.getImageData(5,5,1,1).data[3]<128}return l})().then((t=>{null==e||e(t?[u]:[])})):null==e||e([]),i)};return Object.assign(e,i)};let p,m;function u(t){const e="object"==typeof t&&t.target||document,i="object"==typeof t?t.zoom:t;window[g]||(Object.defineProperty(window,g,{configurable:!0,value:!0}),document.body.style.zoom=1.0001,document.body.offsetHeight,document.body.style.zoom=1,window.addEventListener("message",(({data:t,origin:e})=>{if(e===window.origin)try{if(t&&"string"==typeof t&&t.startsWith(c)){const[,e]=t.split(","),i=Number.parseFloat(e);Number.isNaN(i)||(m=i)}}catch(t){console.error(t)}})));let n=!1;Array.from(e.querySelectorAll("svg[data-marpit-svg]"),(t=>{var e,s,o,r;t.style.transform||(t.style.transform="translateZ(0)");const a=i||m||t.currentScale||1;p!==a&&(p=a,n=a);const l=t.getBoundingClientRect(),{length:c}=t.children;for(let i=0;i{null==t||t.postMessage(`${c}${n}`,"null"===window.origin?"*":window.origin)}))}function v({once:t=!1,target:e=document}={}){const i=function(t=document){if(t[d])return t[d];let e=!0;const i=()=>{e=!1,delete t[d]};Object.defineProperty(t,d,{configurable:!0,value:i});let n=[],s=!1;(async()=>{try{n=await h()}finally{s=!0}})();const o=()=>{for(const e of n)e({target:t});s&&0===n.length||e&&window.requestAnimationFrame(o)};return o(),i}(e);return t?(i(),()=>{}):i}p=1,m=void 0;const b=Symbol(),w=(e=document)=>{if("undefined"==typeof window)throw new Error("Marp Core's browser script is valid only in browser context.");if(((e=document)=>{const i=window[a];i||customElements.define("marp-auto-scaling",s);for(const n of Object.keys(t)){const s=`marp-${n}`,a=t[n].proto();null!=r||(r=!!document.createElement("div",{is:"marp-auto-scaling"}).outerHTML.startsWith("
{t.outerHTML=t.outerHTML.replace(new RegExp(`^<${n}`,"i"),`<${s}`).replace(new RegExp(`${n}>$`,"i"),`${s}>`)})))}window[a]=!0})(e),e[b])return e[b];const i=v({target:e}),n=()=>{i(),delete e[b]},l=Object.assign(n,{cleanup:n,update:()=>w(e)});return Object.defineProperty(e,b,{configurable:!0,value:l}),l},y=document.currentScript;w(y?y.getRootNode():document)}();

W3Cには他にもhogehogeみたいなコミュニティグループがある

コントロール: コントロールは、何らかの形でユーザーとのインタラクションを可能にするコンポーネントの一種。 コントロールには、定義された部分に対するエンドユーザーのインタラクションに基づいて、コンポーネントの状態やモデルの遷移を管理するコントローラコードがある。

複合コンポーネント: 多くの場合、他のコンポーネントを含むコンポーネントを作成する必要がある。たとえば、<file> コントロールは、通常、<input> コンポーネントと <button> コンポーネントを含むコンポーネント。このようなタイプのコンポーネントは、一般に複合コンポーネントと呼ばれる。

- `<select>`, `<input type="date">`, `<input type="color">`, JSで実装しているUIコンポーネンとなどなどたくさん

- 現状のWeb UIをリプレイスするのではなく、独自のデザインシステムを構築するための基盤となる方法を新たに提供しようという方針

様々な独自手法が編み出される

- アクセシビリティ, 国際化, プライバシー, セキュリティー, UX, デフォルトスタイル, デフォルトイベント, バリエーション

StagesはTC39のStagesと同様の概念

実際、厳密にstage自体の運用はされてないみたいだけど、Open UIの提案がどんな流れでshipまで進むのかの指標として

たくさんのコントロールやAPIが提案されていますが、ここでは実際にOpen UIの提案によって実現された/されなかったコントロールの例を見ていきます

It’s difficult to track relative positioning because a popover is outside of the document flow. But there are use cases where we want to “anchor” our popup to another element, especially when you think about tooltips, alerts, etc… So how can we do this? → The anchoring API

現状の`<select>`要素で柔軟なカスタマイズができない 独自の`<select>`を実装する必要がある ネイティブのフォームコントロールと比較してパフォーマンス、アクセシビリティなどの観点で劣る

=== 現状のSelectからの改良点: 1. カスタマイズ可能性 2. 任意のコンテンツを埋め込める 3. 選択オプション 4. カスタマイズ可能なボタン 5. カスタマイズ可能なオプション 6. カスタマイズ可能なデータリスト ===

listboxはスタイル可能な<select multiple>として提案されたもの

Select要素のmultiple属性やsize属性など様々な属性を考慮してselectlist要素を確定させねばならないが、現状動作が不明確:既存の<select>要素を拡張することで、HTMLの一貫性を保ったままにできる

大規模なHTMLパーサの変更が必要でshipに時間がかかりそう: <select>要素を再利用することで、既存のパーサーの動作を利用し、問題を回避できる

既存Select要素のスタイル変更が容易になって嬉しい:<select>要素に対してappearance:noneを使用することで、スタイルのカスタマイズが可能になる。さらに、appearance:baseのような新しい値を追加することで、開発者がより簡単にスタイルを適用できるようにする。また、スタイリングの柔軟性を持たせるために、<select>要素の内部に<button>や<datalist>を含めることができるようにする。

既存の要素を拡張することで、アクセシビリティや国際化の欠落問題も回避できる。具体的には、<select>要素のパーサーを変更し、新しい子要素(例:<button>や<datalist>)を許可することでアクセシビリティを確保しつつ、カスタマイズ可能なリストボックスを実現

<datalist> を再利用することで、select/selectlist プロジェクトのすべてのユースケースを確実に満たすことができますし、出荷もより簡単で速くなる

[<selectlist>の実装に関する様々なIssue](https://github.com/openui/open-ui/issues?q=is%3Aissue+is%3Aopen+label%3Aselect+selectlist)があり、WHATWGとの議論の末、**既存`<select>`の改良を採用**

^1: [Update selectlist explainer to stylable select #976](https://github.com/openui/open-ui/pull/976), [Customizable <select> element #9799](https://github.com/whatwg/html/issues/9799) ^2: [resolved - selectlist feedback from apple #970](https://github.com/openui/open-ui/issues/970#issuecomment-1846026582)

- `skeleton`role: そのコンテンツがplaceholderであり、別のコンテンツが表示が期待されることを表す

--- ### Open UIとAPG --- ### Opne UIとデザインシステム