semantic-forms 5.3.4 → 5.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,61 @@
1
1
  /******/var __webpack_modules__={
2
- /***/"./lib/helpers.js":
3
- /*!************************!*\
4
- !*** ./lib/helpers.js ***!
5
- \************************/
6
- /***/module=>{
7
- /**
8
- * Uses the navigator to best determine the clients operating system.
9
- * @returns Operating system string (`mac`, `windows`, `linux`)
10
- */
11
- const getOS=()=>{const userAgent=window.navigator.userAgent;const platform=window.navigator.platform;let os=null;if(platform.includes("Win"))os="windows";else if(platform.includes("Mac")||/iPhone|iPad|iPod/.test(userAgent))os="mac";else if(platform.includes("Linux")||/Android/.test(userAgent))os="linux";return os}
12
- /**
13
- * Places an element immediately after another element
14
- * @param {Object} newNode element being placed after the reference node
15
- * @param {*} referenceNode element to be used as reference for new node
16
- */;const insertAfter=(newNode,referenceNode)=>{if(referenceNode.nextSibling)referenceNode.parentNode.insertBefore(newNode,referenceNode.nextSibling);else referenceNode.parentNode.appendChild(newNode)};module.exports={getOS,insertAfter}
2
+ /***/"./lib/helpers.js"(module){
3
+ // uses the navigator to best determine the client's operating system
4
+ // returns one of `mac`, `windows`, or `linux`
5
+ const getOS=()=>{const userAgent=window.navigator.userAgent;const platform=window.navigator.platform;let os=null;if(platform.includes("Win"))os="windows";else if(platform.includes("Mac")||/iPhone|iPad|iPod/.test(userAgent))os="mac";else if(platform.includes("Linux")||/Android/.test(userAgent))os="linux";return os};
6
+ // places an element immediately after another element
7
+
8
+ // newNode: the element being placed after the reference node
9
+ // referenceNode: the element the new node is placed after
10
+ const insertAfter=(newNode,referenceNode)=>{if(referenceNode.nextSibling)referenceNode.parentNode.insertBefore(newNode,referenceNode.nextSibling);else referenceNode.parentNode.appendChild(newNode)};
11
+ // works out how far a font's visible text sits from the middle of its own line box
12
+
13
+ // browsers place a line of text by the font's ascent and descent, which are rarely symmetrical, so the letters themselves end up slightly above or below the middle of the box holding them. that is invisible at the half pixel it usually amounts to, until a display without a high pixel density rounds it up to a whole one. the shift is the same for every element using the font, so it is measured once per font and reused
14
+
15
+ // returns the number of pixels the text sits below the middle of its box, negative when it sits above
16
+ const inkOffsets=new Map;const getInkOffset=(fontStyle,fontWeight,fontSize,fontFamily)=>{const key=`${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;if(inkOffsets.has(key))return inkOffsets.get(key);
17
+ // the ascent is where the baseline falls in a line box, which an inline-block aligned to the baseline reports directly
18
+ const probe=document.createElement("div");probe.style.cssText=`position:absolute;left:-9999px;top:0;white-space:nowrap;visibility:hidden;line-height:normal;padding:0;border:0;margin:0;font-style:${fontStyle};font-weight:${fontWeight};font-size:${fontSize};font-family:${fontFamily}`;const marker=document.createElement("span");marker.style.cssText="display:inline-block;width:0;height:0;vertical-align:baseline";probe.append("Hxn",marker);document.body.append(probe);const lineBox=probe.getBoundingClientRect();const ascent=marker.getBoundingClientRect().top-lineBox.top;const descent=lineBox.height-ascent;probe.remove();
19
+ // the cap height is where the letters actually start, which only the drawn pixels can say. drawing it large keeps the answer accurate to a fraction of a pixel
20
+ const scale=8;const size=parseFloat(fontSize);const canvas=document.createElement("canvas");canvas.width=Math.ceil(size*scale*4);canvas.height=Math.ceil(size*scale*3);const context=canvas.getContext("2d",{willReadFrequently:true});context.fillStyle="#fff";context.fillRect(0,0,canvas.width,canvas.height);context.fillStyle="#000";context.font=`${fontStyle} ${fontWeight} ${size*scale}px ${fontFamily}`;context.textBaseline="alphabetic";const baseline=Math.round(size*scale*2);context.fillText("Hxn",10,baseline);const pixels=context.getImageData(0,0,canvas.width,canvas.height).data;let inkTop=null;for(let y=0;y<canvas.height&&inkTop===null;y++)for(let x=0;x<canvas.width;x++)if(pixels[(y*canvas.width+x)*4]<128){inkTop=y;break}if(inkTop===null){const none={offset:0,fieldOffset:0};inkOffsets.set(key,none);return none}const capHeight=(baseline-inkTop)/scale;
21
+ // rounded to whole device pixels, because browsers put a line of text on the pixel grid rather than at the fraction of one asked for. a correction finer than that either does nothing or moves the letters a whole pixel, depending on which side of the halfway mark the sum lands
22
+
23
+ // two roundings come out of this, because a measurement landing exactly halfway can go either way and the browser does not send every kind of text the same way. our own labels follow the ordinary rounding; the text a field draws inside itself, and the text on a button, follow the one that rounds a half away from zero. they differ only for a measurement of exactly minus a half, and agree everywhere else
24
+ const ratio=window.devicePixelRatio||1;const exact=((ascent-descent)/2-capHeight/2)*ratio;const offset=Math.round(exact)/ratio;const fieldOffset=Math.sign(exact)*Math.round(Math.abs(exact))/ratio;const pair={offset,fieldOffset};inkOffsets.set(key,pair);return pair};
25
+ // puts the measured offset on the form as a custom property, for the stylesheet to subtract when it centers the float label on its field
26
+ const applyInkOffset=(form,label)=>{if(!label.isConnected)return;const style=window.getComputedStyle(label);const{offset,fieldOffset}=getInkOffset(style.fontStyle,style.fontWeight,style.fontSize,style.fontFamily);form.style.setProperty("--semanticFormsTextInkOffset",`${offset}px`);form.style.setProperty("--semanticFormsFieldTextInkOffset",`${fieldOffset}px`)};
27
+ // a web font that is still downloading when a form is enhanced is measured in whichever font the page falls back to, and the letters move once the real one arrives. the forms measured so far are remembered so they can be measured again at that point, which the browser reports once for the page rather than once per font
28
+ const measured=new Map;let awaitingFonts=false;const setInkOffset=(form,label)=>{measured.set(form,label);applyInkOffset(form,label);if(awaitingFonts||!document.fonts)return;awaitingFonts=true;document.fonts.ready.then(()=>{
29
+ // the cache is keyed by what the font is called, which does not change when the file behind it finally loads
30
+ inkOffsets.clear();for(const[form,label]of measured)applyInkOffset(form,label);measured.clear()})};module.exports={getOS,insertAfter,setInkOffset}
17
31
  /***/},
18
- /***/"./lib/inputEnhancements.js":
19
- /*!**********************************!*\
20
- !*** ./lib/inputEnhancements.js ***!
21
- \**********************************/
22
- /***/(module,__unused_webpack_exports,__webpack_require__)=>{const{insertAfter}=__webpack_require__(/*! ./helpers.js */"./lib/helpers.js");const icons={passwordShow:'<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m1 12s4-8 11-8 11 8 11 8"/><path d="m1 12s4 8 11 8 11-8 11-8"/><circle cx="12" cy="12" r="3"/></g></svg>',passwordHide:'<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m2 2 20 20"/><path d="m6.71277 6.7226c-3.04798 2.07267-4.71277 5.2774-4.71277 5.2774s3.63636 7 10 7c2.0503 0 3.8174-.7266 5.2711-1.7116m-6.2711-12.23018c.3254-.03809.6588-.05822 1-.05822 6.3636 0 10 7 10 7s-.6918 1.3317-2 2.8335"/><path d="m14 14.2362c-.5308.475-1.2316.7639-2 .7639-1.6569 0-3-1.3431-3-3 0-.8237.33193-1.5698.86932-2.11192"/></g></svg>',helpIcon:'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" version="1.1" style="overflow:visible;"><rect width="16" height="16" fill="none"/><path fill="currentColor" d="M8,10.5c-0.552,0-1,0.448-1,1c0,0.552,0.448,1,1,1c0.552,0,1-0.448,1-1C9,10.948,8.552,10.5,8,10.5z M8,0 C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8s8-3.582,8-8C16,3.582,12.418,0,8,0z M12.243,12.243C11.109,13.376,9.603,14,8,14 s-3.109-0.624-4.243-1.757C2.624,11.109,2,9.603,2,8s0.624-3.109,1.757-4.243C4.891,2.624,6.397,2,8,2s3.109,0.624,4.243,1.757 C13.376,4.891,14,6.397,14,8C14,9.603,13.376,11.109,12.243,12.243z M7.927,3.755c-2.248,0-2.76,1.695-2.802,2.906h1.571 c0.028-0.256,0.101-1.418,1.221-1.418c0.672,0,1.206,0.406,1.206,1.175c0,0.695-0.724,1.155-1.303,1.711 C7.241,8.685,7.168,9.325,7.168,9.824V10c0.146,0,1.615,0,1.615,0V9.824c0-1.256,1.967-1.594,1.967-3.541 C10.75,5.059,9.874,3.755,7.927,3.755z"/></svg>'};const lookups={nodeName:["TEXTAREA","SELECT"],inputType:["checkbox","color","date","datetime-local","email","file","image","month","number","password","radio","range","search","tel","text","time","url","week"]};let lastFocusedInput;let lastClearFieldPressed;const enhanceInput=(input,form)=>{
32
+ /***/"./lib/inputEnhancements.js"(module,__unused_webpack_exports,__webpack_require__){const{insertAfter}=__webpack_require__(/*! ./helpers.js */"./lib/helpers.js");const icons={passwordShow:'<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m1 12s4-8 11-8 11 8 11 8"/><path d="m1 12s4 8 11 8 11-8 11-8"/><circle cx="12" cy="12" r="3"/></g></svg>',passwordHide:'<svg fill="none" height="256" viewBox="0 0 24 24" width="256" xmlns="http://www.w3.org/2000/svg"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m2 2 20 20"/><path d="m6.71277 6.7226c-3.04798 2.07267-4.71277 5.2774-4.71277 5.2774s3.63636 7 10 7c2.0503 0 3.8174-.7266 5.2711-1.7116m-6.2711-12.23018c.3254-.03809.6588-.05822 1-.05822 6.3636 0 10 7 10 7s-.6918 1.3317-2 2.8335"/><path d="m14 14.2362c-.5308.475-1.2316.7639-2 .7639-1.6569 0-3-1.3431-3-3 0-.8237.33193-1.5698.86932-2.11192"/></g></svg>',helpIcon:'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" version="1.1" style="overflow:visible;"><rect width="16" height="16" fill="none"/><path fill="currentColor" d="M8,10.5c-0.552,0-1,0.448-1,1c0,0.552,0.448,1,1,1c0.552,0,1-0.448,1-1C9,10.948,8.552,10.5,8,10.5z M8,0 C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8s8-3.582,8-8C16,3.582,12.418,0,8,0z M12.243,12.243C11.109,13.376,9.603,14,8,14 s-3.109-0.624-4.243-1.757C2.624,11.109,2,9.603,2,8s0.624-3.109,1.757-4.243C4.891,2.624,6.397,2,8,2s3.109,0.624,4.243,1.757 C13.376,4.891,14,6.397,14,8C14,9.603,13.376,11.109,12.243,12.243z M7.927,3.755c-2.248,0-2.76,1.695-2.802,2.906h1.571 c0.028-0.256,0.101-1.418,1.221-1.418c0.672,0,1.206,0.406,1.206,1.175c0,0.695-0.724,1.155-1.303,1.711 C7.241,8.685,7.168,9.325,7.168,9.824V10c0.146,0,1.615,0,1.615,0V9.824c0-1.256,1.967-1.594,1.967-3.541 C10.75,5.059,9.874,3.755,7.927,3.755z"/></svg>'};const lookups={nodeName:["TEXTAREA","SELECT"],inputType:["checkbox","color","date","datetime-local","email","file","image","month","number","password","radio","range","search","tel","text","time","url","week"]};let lastFocusedInput;let lastClearFieldPressed;
33
+ // browsers that implement the switch attribute expose it on the input prototype, render the control as a switch and report the switch role themselves, so nothing below is needed there
34
+ const nativeSwitchSupport="switch"in window.HTMLInputElement.prototype;
35
+ // lets a polyfilled switch be toggled by dragging its thumb, which is what the native control does. a pointer that never travels far enough is left alone so it still lands as a click.
36
+ const enableSwitchDragging=input=>{input.addEventListener("pointerdown",event=>{if(input.disabled)return;const startX=event.clientX;const rightToLeft=window.getComputedStyle(input).direction==="rtl";
37
+ // half the track is the distance the thumb travels between the two positions
38
+ const travel=input.clientWidth/2||1;const wasChecked=input.checked;let dragging=false;input.setPointerCapture(event.pointerId);const drag=moveEvent=>{const distance=rightToLeft?startX-moveEvent.clientX:moveEvent.clientX-startX;if(!dragging&&Math.abs(distance)<=5)return;// still within a click
39
+ dragging=true;moveEvent.preventDefault();// don't select or scroll while dragging
40
+ input.checked=(wasChecked?1:0)+distance/travel>.5};const drop=upEvent=>{input.releasePointerCapture(upEvent.pointerId);input.removeEventListener("pointermove",drag);input.removeEventListener("pointerup",drop);input.removeEventListener("pointercancel",drop);if(!dragging)return;
41
+ // the click that follows the drag would otherwise put the switch back where it started
42
+ input.addEventListener("click",clickEvent=>clickEvent.preventDefault(),{once:true,capture:true});if(input.checked!==wasChecked){input.dispatchEvent(new Event("input",{bubbles:true}));input.dispatchEvent(new Event("change",{bubbles:true}))}};input.addEventListener("pointermove",drag);input.addEventListener("pointerup",drop);input.addEventListener("pointercancel",drop)})};const enhanceInput=(input,form)=>{
23
43
  // ignore input if it has previously been formatted
24
44
  if(input.classList.contains("semanticform")||!input.id)return;const type=input.getAttribute("type");if(!lookups.nodeName.includes(input.nodeName)&&!lookups.inputType.includes(type))return;
25
45
  // recursively find <dl> element
26
- let dl=input.parentNode;while(dl&&dl.nodeName!=="DL")dl=dl.parentNode;if(!dl)return;if(!dl.classList.contains("floatLabelForm"))dl.classList.add("floatLabelForm");const label=input.parentNode.parentNode.id&&(type==="checkbox"||type==="radio")?document.querySelector("label[data-for="+input.parentNode.parentNode.id.replace(/\./g,"\\.")+"]"):document.querySelector("label[for="+input.id.replace(/\./g,"\\.")+"]");if(!label)console.error(`semantic-forms: Found an input (${input.id||input.getAttribute("name")}) without a properly associated label. Make sure there is a label for it and the label has a matching "for" attribute.`);input.classList.add("semanticform");const isWrapped=input.closest("dd").parentElement.nodeName==="DIV";
46
+ let dl=input.parentNode;while(dl&&dl.nodeName!=="DL")dl=dl.parentNode;if(!dl)return;if(!dl.classList.contains("floatLabelForm"))dl.classList.add("floatLabelForm");
47
+ // the label is looked up in the input's own tree rather than the document, so that a form inside a shadow root finds its labels instead of coming up empty
48
+ const tree=input.getRootNode();const label=input.parentNode.parentNode.id&&(type==="checkbox"||type==="radio")?tree.querySelector("label[data-for="+input.parentNode.parentNode.id.replace(/\./g,"\\.")+"]"):tree.querySelector("label[for="+input.id.replace(/\./g,"\\.")+"]");if(!label){console.error(`semantic-forms: Found an input (${input.id||input.getAttribute("name")}) without a properly associated label. Make sure there is a label for it and the label has a matching "for" attribute.`);return}input.classList.add("semanticform");
49
+ // an input outside a <dd> is reported by the checks further down, so tolerate it here
50
+ const isWrapped=input.closest("dd")?.parentElement.nodeName==="DIV";
27
51
  // #region create labels
28
52
  const newLabel=document.createElement("label");newLabel.className="floatLabelFormAnimatedLabel";if(type==="checkbox"||type==="radio"){const div=isWrapped?input.closest("dd").parentElement:document.createElement("div");const dt=label.closest("dt");const dd=input.closest("dd");if(!dt||!dd){console.error(`semantic-forms: Found an input (${input.id||input.getAttribute("name")}) that is not inside a <dd> element.`);return}if(dd.firstChild.nodeName!=="LABEL"){if(type==="checkbox"&&input.parentNode.nodeName==="DD"){newLabel.setAttribute("for",input.id);input.parentNode.classList.add("singleCheckbox");newLabel.className="";label.setAttribute("hidden","hidden");insertAfter(newLabel,input)}if(type==="radio"&&input.parentNode.nodeName==="DD"){newLabel.setAttribute("for",input.id);input.parentNode.classList.add("singleRadio");newLabel.className="";label.setAttribute("hidden","hidden");insertAfter(newLabel,input)}newLabel.innerHTML=label.innerHTML;if(label.hasAttribute("title")&&label.getAttribute("data-show-help-icon")!==null&&!label.querySelector("span.help")){const text=label.getAttribute("title");label.innerHTML+=` <span title="${text}" class="help">${icons.helpIcon}</span>`;newLabel.innerHTML+=` <span title="${text}" class="help">${icons.helpIcon}</span>`}if(dd.querySelector(":required")&&label.getAttribute("data-no-asterisk")===null&&!label.querySelector("span.required")){const text=label.getAttribute("data-asterisk-text")||"This field is required.";label.innerHTML+=` <span title="${text}" class="required">*</span>`;newLabel.innerHTML+=` <span title="${text}" class="required">*</span>`}if(!dd.querySelector("label"))dd.append(newLabel)}
29
53
  // if the user did not wrap the input in a div, do it for them
30
- if(!isWrapped){div.append(dt,dd);
54
+ if(!isWrapped){
55
+ // the wrapper goes where the <dt> was, so the field keeps its place in the markup. a div the author wrote is already in the right place and is deliberately left where it is: moving it would push the field past anything the enhancer does not touch, such as a <dd> holding only a button
56
+ dt.before(div);div.append(dt,dd);
31
57
  // determine visibility of newly created <div>
32
- if(dt.style.display==="none"&&dd.style.display==="none")div.style.display="none"}dl.append(div)}else{newLabel.setAttribute("for",input.id);newLabel.innerHTML=label.innerHTML;if(input.hasAttribute("title")&&label.getAttribute("data-show-help-icon")!==null&&!label.querySelector("span.help")){const text=input.getAttribute("title");newLabel.innerHTML+=` <span title="${text}" class="help">${icons.helpIcon}</span>`}if(input.hasAttribute("required")&&label.getAttribute("data-no-asterisk")===null&&!label.querySelector("span.required")){const text=label.getAttribute("data-asterisk-text")||"This field is required.";newLabel.innerHTML+=` <span title="${text}" class="required">*</span>`}label.setAttribute("hidden","hidden");insertAfter(newLabel,input)}
58
+ if(dt.style.display==="none"&&dd.style.display==="none")div.style.display="none"}}else{newLabel.setAttribute("for",input.id);newLabel.innerHTML=label.innerHTML;if(input.hasAttribute("title")&&label.getAttribute("data-show-help-icon")!==null&&!label.querySelector("span.help")){const text=input.getAttribute("title");newLabel.innerHTML+=` <span title="${text}" class="help">${icons.helpIcon}</span>`}if(input.hasAttribute("required")&&label.getAttribute("data-no-asterisk")===null&&!label.querySelector("span.required")){const text=label.getAttribute("data-asterisk-text")||"This field is required.";newLabel.innerHTML+=` <span title="${text}" class="required">*</span>`}label.setAttribute("hidden","hidden");insertAfter(newLabel,input)}
33
59
  // #endregion
34
60
  // #region standard inputs
35
61
  // check for auto-grow attribute on textareas
@@ -38,27 +64,30 @@ if(input.getAttribute("data-auto-grow")!==null){
38
64
  if(input.nodeName==="INPUT"&&input.type==="text"){const newInput=document.createElement("textarea");newInput.id=input.id;newInput.class=input.class;newInput.innerText=input.value;newInput.setAttribute("data-auto-grow","");input.replaceWith(newInput);input=newInput}if(input.nodeName==="TEXTAREA")
39
65
  // when pressing enter while this input is focused, we want to submit
40
66
  input.addEventListener("keypress",e=>{if(e.key!=="Enter"||e.key==="Enter"&&e.shiftKey)return;e.preventDefault();form.requestSubmit()})}if(type!=="checkbox"&&type!=="radio"){if(!input.getAttribute("placeholder"))input.setAttribute("placeholder"," ");const div=isWrapped?input.closest("dd").parentElement:document.createElement("div");const dt=label.closest("dt");const dd=input.closest("dd");if(!dt||!dd){console.error(`semantic-forms: Found an input (${input.id||input.getAttribute("name")}) that does not have a corresponding ${!dt?"<dt>":"<dd>"} element.`);return}
41
- // input clear button
42
- if(input.nodeName!=="SELECT"&&type!=="range"){const clearBtn=document.createElement("button");clearBtn.type="button";clearBtn.title=input.getAttribute("data-clear-field-text")||"Clear field";clearBtn.ariaLabel=input.getAttribute("data-clear-field-text")||"Clear field";clearBtn.tabIndex=-1;clearBtn.innerHTML='<svg viewBox="0 0 16 16" width="18" height="18"><path d="M 1 1 L 15 15 M 1 15 L 15 1" fill="none" stroke-width="2" stroke="currentColor" />';clearBtn.classList.add("clear");clearBtn.id=`semanticFormsClearButton_${input.id}`;clearBtn.addEventListener("click",()=>{input.previousValue=input.value;input.value="";input.focus();lastClearFieldPressed=input.id;
67
+ // input clear button. a field the user cannot edit does not get one, since the button would empty it anyway. a field that only becomes uneditable later is handled in css
68
+ if(input.nodeName!=="SELECT"&&type!=="range"&&!input.readOnly&&!input.disabled){const clearBtn=document.createElement("button");clearBtn.type="button";clearBtn.title=input.getAttribute("data-clear-field-text")||"Clear field";clearBtn.ariaLabel=input.getAttribute("data-clear-field-text")||"Clear field";clearBtn.tabIndex=-1;clearBtn.innerHTML='<svg viewBox="0 0 16 16" width="18" height="18"><path d="M 1 1 L 15 15 M 1 15 L 15 1" fill="none" stroke-width="2" stroke="currentColor" />';clearBtn.classList.add("clear");clearBtn.id=`semanticFormsClearButton_${input.id}`;clearBtn.addEventListener("click",()=>{input.previousValue=input.value;input.value="";input.focus();lastClearFieldPressed=input.id;
43
69
  // used for any other updates required by various inputs
44
70
  input.dispatchEvent(new Event("input",{bubbles:true}))});insertAfter(clearBtn,dd.querySelector("label"))}input.addEventListener("focus",event=>{if(event.target.nodeName==="INPUT")lastFocusedInput=event.target});
45
- // check for colspan- utility class on dd, and move to div
46
- if(/colspan-/.test(dd.className)){const match=dd.className.match(/colspan-([0-9]|full)/)[0];dd.classList.remove(match);div.classList.add(match)}
71
+ // the colspan- and col- utility classes are documented as going on the dd, but they size and place the grid item, which is the div wrapping it, so move any of them across
72
+ for(const utility of dd.className.match(/colspan-(?:[0-9]|full)|col-[0-9]/g)||[]){dd.classList.remove(utility);div.classList.add(utility)}
47
73
  // check for max-content attribute
48
74
  // this may be removed once fully supported in Firefox and Safari: https://caniuse.com/wf-field-sizing
49
75
  if(input.getAttribute("data-max-content")!==null)if(!("fieldSizing"in document.createElement("input").style)){const adjustWidth=()=>{const value=input.value!==""?input.value:input.placeholder;const width=value.length*8+40;input.style.width=width+"px";input.style.maxWidth="100%"};adjustWidth();input.addEventListener("input",adjustWidth)}
50
76
  // if the user did not wrap the input in a div, do it for them
51
- if(!isWrapped){div.append(dt,dd);
77
+ if(!isWrapped){
78
+ // as above, the wrapper takes the <dt>'s position rather than being appended to the end
79
+ dt.before(div);div.append(dt,dd);
52
80
  // determine visibility of newly created <div>
53
- if(dt.style.display==="none"&&dd.style.display==="none")div.style.display="none"}dl.append(div)}
81
+ if(dt.style.display==="none"&&dd.style.display==="none")div.style.display="none"}}
54
82
  // #endregion
55
83
  // handle file input clear btn, cannot be handled with CSS
56
84
  if(type==="file"){const clearBtn=input.parentElement.querySelector(".clear");input.addEventListener("input",event=>{clearBtn.style.display=event.target.files.length?"flex":"none"});clearBtn.addEventListener("click",()=>{clearBtn.style.display="none"})}
57
- // handle range inputs with a class to display the value
58
- // TODO: should this be a data attribute instead?
59
- if(type==="range"&&input.classList.contains("displayValue")){const label=input.parentNode.parentNode.querySelector("dd label");label.innerHTML+=`<span class="seperator">: </span><output>${input.value}</output>`;input.addEventListener("input",event=>{const output=event.target.parentNode.parentNode.querySelector("output");output.innerHTML=event.target.value})}
85
+ // handle range inputs set to display the value. every other per-field flag in the library is a data attribute, so that is the documented way to ask for this, but the original `displayValue` class is still honored
86
+ if(type==="range"&&(input.getAttribute("data-display-value")!==null||input.classList.contains("displayValue"))){const label=input.parentNode.parentNode.querySelector("dd label");label.innerHTML+=`<span class="seperator">: </span><output>${input.value}</output>`;input.addEventListener("input",event=>{const output=event.target.parentNode.parentNode.querySelector("output");output.innerHTML=event.target.value})}
60
87
  // show password button
61
88
  if(type==="password"&&input.getAttribute("data-no-reveal")===null){const showBtn=document.createElement("button");showBtn.type="button";showBtn.title=input.getAttribute("data-show-password-text")||"Show password";showBtn.ariaLabel=input.getAttribute("data-show-password-text")||"Show password";showBtn.tabIndex=-1;showBtn.innerHTML=icons.passwordShow;showBtn.classList.add("show");showBtn.id=`semanticFormsShowButton_${input.id}`;const dd=input.closest("dd");if(!dd){console.error(`semantic-forms: Found an input (${input.id||input.getAttribute("name")}) that is not inside a <dd> element.`);return}showBtn.addEventListener("click",()=>{if(input.type==="password"){showBtn.innerHTML=icons.passwordHide;showBtn.title=input.getAttribute("data-hide-password-text")||"Hide password";showBtn.ariaLabel=input.getAttribute("data-hide-password-text")||"Hide password";input.type="text"}else{showBtn.innerHTML=icons.passwordShow;showBtn.title=input.getAttribute("data-show-password-text")||"Show password";showBtn.ariaLabel=input.getAttribute("data-show-password-text")||"Show password";input.type="password"}input.focus()});insertAfter(showBtn,dd.querySelector("label"))}
89
+ // a checkbox with the switch attribute is a switch; the stylesheet draws one in every browser, and where the attribute is not implemented natively the role and dragging are supplied here
90
+ if(type==="checkbox"&&input.hasAttribute("switch")&&!nativeSwitchSupport){input.setAttribute("role","switch");enableSwitchDragging(input)}
62
91
  // add listener to shift clear button when scrollbar present
63
92
  if(input.nodeName==="TEXTAREA"){if(input.getAttribute("data-disable-autosize")===null){
64
93
  // add auto-sizing
@@ -91,15 +120,15 @@ const shiftClearBtn=()=>{const clearBtn=input.parentElement?.querySelector("butt
91
120
  // handle undo/redo
92
121
  if((event.ctrlKey||event.metaKey)&&event.key==="z"&&!event.shiftKey){
93
122
  // undo clearing a field
94
- if(lastFocusedInput)if(lastFocusedInput?.parentNode?.querySelector("button.clear").id===`semanticFormsClearButton_${lastClearFieldPressed}`||lastFocusedInput?.parentNode?.querySelector("button.clear").name===`semanticFormsClearButton_${lastClearFieldPressed}`)if(lastFocusedInput.previousValue){lastFocusedInput.redoValue=lastFocusedInput.value;lastFocusedInput.value=lastFocusedInput.previousValue}}else if(event.ctrlKey&&event.key==="y"||event.metaKey&&event.shiftKey&&event.key==="z")
123
+ if(lastFocusedInput)if(lastFocusedInput?.parentNode?.querySelector("button.clear")?.id===`semanticFormsClearButton_${lastClearFieldPressed}`||lastFocusedInput?.parentNode?.querySelector("button.clear")?.name===`semanticFormsClearButton_${lastClearFieldPressed}`)if(lastFocusedInput.previousValue){
124
+ // the browser undoes the field's own edit history on this key too, which would apply on top of the value restored here, so the native behavior is suppressed
125
+ event.preventDefault();lastFocusedInput.redoValue=lastFocusedInput.value;lastFocusedInput.value=lastFocusedInput.previousValue}}else if(event.ctrlKey&&event.key==="y"||event.metaKey&&event.shiftKey&&event.key==="z")
95
126
  // redo clearing a field
96
- if(lastFocusedInput)if(lastFocusedInput?.parentNode?.querySelector("button.clear").id===`semanticFormsClearButton_${lastClearFieldPressed}`||lastFocusedInput?.parentNode?.querySelector("button.clear").name===`semanticFormsClearButton_${lastClearFieldPressed}`)if(lastFocusedInput.redoValue){lastFocusedInput.previousValue=lastFocusedInput.value;lastFocusedInput.value=lastFocusedInput.redoValue}};module.exports={enhanceInput,handleUndoRedo}
127
+ if(lastFocusedInput)if(lastFocusedInput?.parentNode?.querySelector("button.clear")?.id===`semanticFormsClearButton_${lastClearFieldPressed}`||lastFocusedInput?.parentNode?.querySelector("button.clear")?.name===`semanticFormsClearButton_${lastClearFieldPressed}`)if(lastFocusedInput.redoValue){
128
+ // as above, the browser's own redo would otherwise apply on top of this
129
+ event.preventDefault();lastFocusedInput.previousValue=lastFocusedInput.value;lastFocusedInput.value=lastFocusedInput.redoValue}};module.exports={enhanceInput,handleUndoRedo}
97
130
  /***/},
98
- /***/"./lib/keyboardShortcuts.js":
99
- /*!**********************************!*\
100
- !*** ./lib/keyboardShortcuts.js ***!
101
- \**********************************/
102
- /***/(module,__unused_webpack_exports,__webpack_require__)=>{const{getOS,insertAfter}=__webpack_require__(/*! ./helpers.js */"./lib/helpers.js");const specialCharMap={Minus:"-",Equal:"=",BracketLeft:"[",BracketRight:"]",Backslash:"\\",Semicolon:";",Quote:"'",Comma:",",Period:".",Slash:"/",Backquote:"`"};const shiftSpecialCharMap={1:"!",2:"@",3:"#",4:"$",5:"%",6:"^",7:"&",8:"*",9:"(",0:")",Minus:"_",Equal:"+",BracketLeft:"{",BracketRight:"}",Backslash:"|",Semicolon:":",Quote:'"',Comma:"<",Period:">",Slash:"?",Backquote:"~"};
131
+ /***/"./lib/keyboardShortcuts.js"(module,__unused_webpack_exports,__webpack_require__){const{getOS,insertAfter}=__webpack_require__(/*! ./helpers.js */"./lib/helpers.js");const specialCharMap={Minus:"-",Equal:"=",BracketLeft:"[",BracketRight:"]",Backslash:"\\",Semicolon:";",Quote:"'",Comma:",",Period:".",Slash:"/",Backquote:"`"};const shiftSpecialCharMap={1:"!",2:"@",3:"#",4:"$",5:"%",6:"^",7:"&",8:"*",9:"(",0:")",Minus:"_",Equal:"+",BracketLeft:"{",BracketRight:"}",Backslash:"|",Semicolon:":",Quote:'"',Comma:"<",Period:">",Slash:"?",Backquote:"~"};
103
132
  // handles keyboard shortcut events
104
133
  const shortcutListener=(e,shortcuts)=>{
105
134
  // search for matching shortcut from cached shortcut configs
@@ -129,46 +158,84 @@ const indicator=document.createElement("span");indicator.classList.add("focus-ke
129
158
  // update the input title
130
159
  if(input.getAttribute("title"))input.setAttribute("title",input.getAttribute("title")+` (${modifierSymbol} + ${focusKey})`);else input.setAttribute("title",`Focus with ${modifierSymbol} + ${focusKey}`);return{key:focusKey,modifier:modifierKey,input,os,defaultModifier}};module.exports={shortcutListener,createKeyboardShortcut}
131
160
  /***/},
132
- /***/"./semanticForms.js":
133
- /*!**************************!*\
134
- !*** ./semanticForms.js ***!
135
- \**************************/
136
- /***/(module,__unused_webpack_exports,__webpack_require__)=>{const{createKeyboardShortcut,shortcutListener}=__webpack_require__(/*! ./lib/keyboardShortcuts.js */"./lib/keyboardShortcuts.js");const{enhanceInput,handleUndoRedo}=__webpack_require__(/*! ./lib/inputEnhancements.js */"./lib/inputEnhancements.js");const semanticForms=()=>{
161
+ /***/"./lib/tabs.js"(module){let tabGroupCount=0;
162
+ // turns a group of fieldsets into a set of tabs
163
+
164
+ // the markup it enhances is an element with the `tabs` class holding one `<fieldset>` per tab, each titled by its `<legend>`. left alone that is a stack of labelled field groups, which is a perfectly usable form, so a browser without JavaScript still gets everything
165
+
166
+ // form: form element to search for tab groups
167
+ const enhanceTabs=form=>{for(const group of form.querySelectorAll(".tabs:not(.semanticFormsTabsActive)")){const panels=Array.from(group.children).filter(child=>child.nodeName==="FIELDSET");
168
+ // one fieldset is not a set of tabs, and no fieldsets means the class is on the wrong element
169
+ if(panels.length<2){if(panels.length===0)console.error("semantic-forms: Found a .tabs element with no <fieldset> children. Each tab needs to be a <fieldset> with a <legend> naming it.",group);continue}group.classList.add("semanticFormsTabsActive");const groupId=++tabGroupCount;const tabList=document.createElement("div");const tabs=[];tabList.classList.add("tabList");tabList.setAttribute("role","tablist");
170
+ // shows one panel and marks its tab as the selected one
171
+ const selectTab=(index,moveFocus)=>{for(const[i,panel]of panels.entries())panel.hidden=i!==index;for(const[i,tab]of tabs.entries()){const selected=i===index;tab.setAttribute("aria-selected",selected?"true":"false");
172
+ // only the selected tab is in the tab order; the arrow keys reach the others
173
+ tab.tabIndex=selected?0:-1}if(moveFocus)tabs[index].focus()};for(const[index,panel]of panels.entries()){const legend=panel.querySelector("legend");const tab=document.createElement("button");tab.type="button";tab.setAttribute("role","tab");tab.id=`semanticFormsTab_${groupId}_${index}`;tab.textContent=legend?legend.textContent:`Tab ${index+1}`;if(!panel.id)panel.id=`semanticFormsTabPanel_${groupId}_${index}`;panel.setAttribute("role","tabpanel");panel.setAttribute("aria-labelledby",tab.id);tab.setAttribute("aria-controls",panel.id);
174
+ // the tab now names the panel, so the legend would be read out twice
175
+ if(legend)legend.hidden=true;tab.addEventListener("click",()=>selectTab(index));tabs.push(tab);tabList.append(tab)}
176
+ // the arrow keys move between tabs, which is what a tablist is expected to do
177
+ tabList.addEventListener("keydown",event=>{const current=tabs.indexOf(document.activeElement);if(current===-1)return;const keys={ArrowRight:(current+1)%tabs.length,ArrowLeft:(current-1+tabs.length)%tabs.length,Home:0,End:tabs.length-1};if(!(event.key in keys))return;event.preventDefault();selectTab(keys[event.key],true)});group.prepend(tabList);selectTab(0)}};module.exports={enhanceTabs}
178
+ /***/},
179
+ /***/"./semanticForms.js"(module,__unused_webpack_exports,__webpack_require__){const{createKeyboardShortcut,shortcutListener}=__webpack_require__(/*! ./lib/keyboardShortcuts.js */"./lib/keyboardShortcuts.js");const{enhanceInput,handleUndoRedo}=__webpack_require__(/*! ./lib/inputEnhancements.js */"./lib/inputEnhancements.js");const{enhanceTabs}=__webpack_require__(/*! ./lib/tabs.js */"./lib/tabs.js");const{setInkOffset}=__webpack_require__(/*! ./lib/helpers.js */"./lib/helpers.js");
180
+ // one observer per tree that has been handed to semanticForms, so that a shadow root gets its own without replacing the document's
181
+ const observers=new WeakMap;
182
+ // progressively enhances every semanticForms form in a tree
183
+
184
+ // root: document or shadow root to search; defaults to the main document
185
+ const semanticForms=(root=document)=>{
137
186
  // do some feature detection so none of the JS executes if the browser is too old
138
187
  if(typeof document.getElementsByClassName!=="function"||typeof document.querySelector!=="function"||!document.body.classList||!window.MutationObserver){console.warn("semantic-forms was loaded into an unsupported browser and will not execute.");return}
139
188
  // custom keyboard shortcut listener
140
189
  const keyboardShortcuts=[];document.addEventListener("keydown",e=>shortcutListener(e,keyboardShortcuts));
141
190
  // progressively enhance form elements that have the semanticForms class
142
- const forms=document.querySelectorAll("form.semanticForms:not(.semanticFormsActive), table.semanticForms:not(.semanticFormsActive)");for(const form of forms){form.classList.add("semanticFormsActive");if(form.classList.contains("lowFlow"))continue;
191
+ const forms=root.querySelectorAll("form.semanticForms:not(.semanticFormsActive), table.semanticForms:not(.semanticFormsActive)");for(const form of forms){form.classList.add("semanticFormsActive");if(form.classList.contains("lowFlow"))continue;
192
+ // how far this font's letters sit from the middle of their own line box, which the stylesheet subtracts wherever it centers text. it is measured per form rather than per field, so that a form holding nothing but buttons is corrected too
193
+ setInkOffset(form,form);
143
194
  // update each input in the semantic form
144
195
  const inputs=Array.from(form.querySelectorAll("input, textarea, select"));for(const input of inputs){enhanceInput(input,form);
145
196
  // handle keyboard shortcuts
146
- if(input.getAttribute("data-focus-key")!==null){const shortcut=createKeyboardShortcut(input,keyboardShortcuts);keyboardShortcuts.push(shortcut)}}}
197
+ if(input.getAttribute("data-focus-key")!==null){const shortcut=createKeyboardShortcut(input,keyboardShortcuts);keyboardShortcuts.push(shortcut)}}
198
+ // done after the inputs so that anything sizing itself measures while it is still on screen
199
+ enhanceTabs(form)}
147
200
  // prevents multiple listeners
148
201
  document.removeEventListener("keydown",handleUndoRedo);document.addEventListener("keydown",handleUndoRedo);
149
- // monitor changes to the DOM and enhance new semanticForms forms that get added
150
- if(!window.semanticFormsObserver){window.semanticFormsObserver=new window.MutationObserver(mutations=>{let stop=false;for(const mutation of mutations){for(const node of mutation.addedNodes)if(node.nodeName==="FORM"||node?.querySelector?.("form")){semanticForms();stop=true}if(stop)break}});window.semanticFormsObserver.observe(document.body,{attributes:false,childList:true,characterData:false,subtree:true})}semanticForms.reinitialize=form=>{form.classList.remove("semanticFormsActive");semanticForms()}};module.exports=semanticForms
202
+ // monitor changes to this tree and enhance new semanticForms forms that get added to it
203
+ if(!observers.has(root)){const observer=new window.MutationObserver(mutations=>{let stop=false;for(const mutation of mutations){for(const node of mutation.addedNodes)if(node.nodeName==="FORM"||node?.querySelector?.("form")){semanticForms(root);stop=true}if(stop)break}});
204
+ // a shadow root is observed directly; the document is observed through its body
205
+ observer.observe(root===document?document.body:root,{attributes:false,childList:true,characterData:false,subtree:true});observers.set(root,observer);
206
+ // long standing name for the document's observer, kept for anything already using it
207
+ if(root===document)window.semanticFormsObserver=observer}semanticForms.reinitialize=form=>{
208
+ // with no form given this just rescans for forms that are not active yet
209
+ if(!form)return semanticForms(root);form.classList.remove("semanticFormsActive");
210
+ // the form knows which tree it belongs to, so a form in a shadow root reinitializes there
211
+ semanticForms(form.getRootNode())}};module.exports=semanticForms
151
212
  /***/}
152
213
  /******/};
153
214
  /************************************************************************/
154
215
  /******/ // The module cache
155
- /******/var __webpack_module_cache__={};
216
+ /******/const __webpack_module_cache__={};
156
217
  /******/
157
218
  /******/ // The require function
158
219
  /******/function __webpack_require__(moduleId){
159
220
  /******/ // Check if module is in cache
160
- /******/var cachedModule=__webpack_module_cache__[moduleId];
221
+ /******/const cachedModule=__webpack_module_cache__[moduleId];
161
222
  /******/if(cachedModule!==void 0)
162
223
  /******/return cachedModule.exports;
163
224
  /******/
164
225
  /******/ // Create a new module (and put it into the cache)
165
- /******/var module=__webpack_module_cache__[moduleId]={
226
+ /******/const module=__webpack_module_cache__[moduleId]={
166
227
  /******/ // no module.id needed
167
228
  /******/ // no module.loaded needed
168
229
  /******/exports:{}
169
230
  /******/};
170
231
  /******/
171
232
  /******/ // Execute the module function
233
+ /******/if(!(moduleId in __webpack_modules__)){
234
+ /******/delete __webpack_module_cache__[moduleId];
235
+ /******/const e=new Error("Cannot find module '"+moduleId+"'");
236
+ /******/e.code="MODULE_NOT_FOUND";
237
+ /******/throw e;
238
+ /******/}
172
239
  /******/__webpack_modules__[moduleId](module,module.exports,__webpack_require__);
173
240
  /******/
174
241
  /******/ // Return the exports of the module
@@ -180,7 +247,7 @@ if(!window.semanticFormsObserver){window.semanticFormsObserver=new window.Mutati
180
247
  /******/ // startup
181
248
  /******/ // Load entry module and return exports
182
249
  /******/ // This entry module is referenced by other modules so it can't be inlined
183
- /******/var __webpack_exports__=__webpack_require__("./semanticForms.js");
184
- /******/export{__webpack_exports__ as default};
250
+ /******/let __webpack_exports__=__webpack_require__("./semanticForms.js");
251
+ /******/export default __webpack_exports__;
185
252
  /******/
186
253
  //# sourceMappingURL=semantic-forms.mjs.map