Changes
12 changed files (+845/-3)
-
-
@@ -28,6 +28,10 @@ compile.addMainElmSourceFile(b.path("src/Main.elm"));compile.addElmSourceFile(b.path("src/Length.elm")); compile.addElmSourceFile(b.path("src/Parameters.elm")); compile.addElmSourceFile(b.path("src/Parameters/Constraints.elm")); compile.addElmSourceFile(b.path("src/Parameters/Form.elm")); compile.addElmSourceFile(b.path("src/Parameters/Key.elm")); compile.addElmSourceFile(b.path("src/Parameters/Parser.elm")); compile.addElmSourceFile(b.path("src/Template.elm")); compile.addElmSourceFile(b.path("src/Svg/Path.elm"));
-
-
-
@@ -11,9 +11,10 @@ module Main exposing (main)import Browser import Browser.Navigation exposing (Key) import Html exposing (node, p, text) import Html exposing (node, p) import Html.Attributes exposing (attribute) import Parameters exposing (Parameters) import Parameters.Form import Template exposing (template) import Url exposing (Url)
-
@@ -46,12 +47,23 @@ type alias Model ={ url : Url , key : Key , parameters : Parameters , parametersForm : Parameters.Form.Model } init : Flags -> Url -> Key -> ( Model, Cmd Msg ) init _ url key = ( { url = url, key = key, parameters = Parameters.default }, Cmd.none ) let parameters = Parameters.default in ( { url = url , key = key , parameters = parameters , parametersForm = Parameters.Form.init parameters } , Cmd.none )
-
@@ -60,6 +72,7 @@type Msg = NoOp | ParametersFormMsg Parameters.Form.Msg | UrlRequested Browser.UrlRequest | UrlChanged Url
-
@@ -69,6 +82,14 @@ update msg model =case msg of NoOp -> ( model, Cmd.none ) ParametersFormMsg (Parameters.Form.ParametersChangeRequested newParams) -> ( { model | parameters = newParams }, Cmd.none ) ParametersFormMsg subMsg -> Parameters.Form.update subMsg model.parametersForm |> Tuple.mapFirst (\p -> { model | parametersForm = p }) |> Tuple.mapSecond (Cmd.map ParametersFormMsg) UrlRequested (Browser.Internal url) -> ( model, Browser.Navigation.replaceUrl model.key (Url.toString url) )
-
@@ -95,7 +116,12 @@ [ attribute "slot" "preview" ][ template model.parameters [] ] , node "x-panel" [ attribute "slot" "parameters" ] [ p [] [ text "Parameters" ] ] [ Parameters.Form.view model.parametersForm model.parameters [] |> Html.map ParametersFormMsg ] ] ] }
-
-
-
@@ -0,0 +1,48 @@-- Copyright 2026 Shota FUJI -- -- This Source Code Form is subject to the terms of the Mozilla Public -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. -- -- SPDX-License-Identifier: MPL-2.0 module Parameters.Constraints exposing (..) import Length exposing (Length, mm) import Parameters type alias NumberConstraints a = { min : Maybe a , max : Maybe a } type alias BuckleHole = { count : NumberConstraints Int , diameter : NumberConstraints Length } type alias LongPiece = { buckleHole : BuckleHole } type alias Parameters = { lugWidth : NumberConstraints Length , longPiece : LongPiece } constraints : Parameters constraints = { lugWidth = { min = Just (mm 10), max = Just (mm 30) } , longPiece = { buckleHole = { count = { min = Just 0, max = Just 10 } , diameter = { min = Just (mm 1), max = Just (mm 10) } } } }
-
-
src/Parameters/Form.elm (new)
-
@@ -0,0 +1,369 @@-- Copyright 2026 Shota FUJI -- -- This Source Code Form is subject to the terms of the Mozilla Public -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. -- -- SPDX-License-Identifier: MPL-2.0 module Parameters.Form exposing (Model, Msg(..), init, update, view) import Dict exposing (Dict) import Html exposing (div, input, label, node, p, span, text) import Html.Attributes exposing (..) import Html.Events exposing (onInput) import Length exposing (Length, toMM) import Parameters exposing (Parameters) import Parameters.Constraints exposing (NumberConstraints, constraints) import Parameters.Key as Key exposing (Key(..)) import Parameters.Parser exposing (Error(..), parseInt, parseLength) import Task -- MODEL type alias Fields = Dict String String type alias Errors = Dict String Error type alias Model = { fields : Fields , errors : Errors } init : Parameters -> Model init params = let fields = Dict.fromList [ ( Key.toString LugWidth, String.fromFloat (toMM params.lugWidth) ) , ( Key.toString BuckleHoleCount, String.fromInt params.longPiece.buckleHole.count ) , ( Key.toString BuckleHoleDiameter, String.fromFloat (toMM params.longPiece.buckleHole.diameter) ) ] in { fields = fields , errors = case parse fields of Ok _ -> Dict.empty Err errors -> errors } -- UPDATE type InternalMsg = FieldChanged Key String type Msg = Internal InternalMsg | ParametersChangeRequested Parameters parseField : Key -> (String -> Result Error a) -> Fields -> Result Error a parseField key f fields = Dict.get (Key.toString key) fields |> Maybe.withDefault "" |> f mkErrors : List ( Key, Maybe Error ) -> Errors mkErrors list = list |> List.filterMap (\( key, error ) -> Maybe.map (\e -> ( Key.toString key, e )) error) |> Dict.fromList getError : Result a b -> Maybe a getError r = case r of Err e -> Just e _ -> Nothing parseBuckleHole : Fields -> Result Errors Parameters.BuckleHole parseBuckleHole fields = case ( parseField BuckleHoleCount (parseInt constraints.longPiece.buckleHole.count) fields , parseField BuckleHoleDiameter (parseLength constraints.longPiece.buckleHole.diameter) fields ) of ( Ok count, Ok diameter ) -> let base = Parameters.default.longPiece.buckleHole in Ok { base | count = count, diameter = diameter } ( count, diameter ) -> Err (mkErrors [ ( BuckleHoleCount, getError count ) , ( BuckleHoleDiameter, getError diameter ) ] ) parseLongPiece : Fields -> Result Errors Parameters.LongPiece parseLongPiece fields = case parseBuckleHole fields of Ok buckleHole -> let base = Parameters.default.longPiece in Ok { base | buckleHole = buckleHole } Err errors -> Err errors parse : Fields -> Result Errors Parameters parse fields = case ( parseField LugWidth (parseLength constraints.lugWidth) fields , parseLongPiece fields ) of ( Ok lugWidth, Ok longPiece ) -> let base = Parameters.default in Ok { base | lugWidth = lugWidth, longPiece = longPiece } ( lugWidth, longPiece ) -> Err (Dict.union (mkErrors [ ( LugWidth, getError lugWidth ) ]) (Maybe.withDefault Dict.empty (getError longPiece))) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Internal (FieldChanged key value) -> let fields = Dict.insert (Key.toString key) value model.fields in case parse fields of Ok newParams -> ( { model | fields = fields, errors = Dict.empty } , Task.perform identity (Task.succeed (ParametersChangeRequested newParams)) ) Err errors -> ( { model | fields = fields, errors = errors }, Cmd.none ) ParametersChangeRequested _ -> ( model, Cmd.none ) -- VIEW lengthFieldAttrs : NumberConstraints Length -> List (Html.Attribute msg) lengthFieldAttrs { min, max } = [ case min of Just x -> toMM x |> String.fromFloat |> Html.Attributes.min Nothing -> class "" , case max of Just x -> toMM x |> String.fromFloat |> Html.Attributes.max Nothing -> class "" , attribute "inputmode" "numeric" , required True ] intFieldAttrs : NumberConstraints Int -> List (Html.Attribute msg) intFieldAttrs { min, max } = [ case min of Just x -> Html.Attributes.min (String.fromInt x) Nothing -> class "" , case max of Just x -> Html.Attributes.max (String.fromInt x) Nothing -> class "" , attribute "inputmode" "numeric" , required True ] ariaInvalid : Bool -> Html.Attribute msg ariaInvalid invalid = attribute "aria-invalid" (if invalid then "true" else "false" ) ariaDescribedBy : List String -> Html.Attribute msg ariaDescribedBy ids = case ids of [] -> class "" _ -> ids |> String.join " " |> attribute "aria-describedby" errorId : Key -> String errorId key = Key.toString key ++ "_error" errorText : Error -> List (Html.Html msg) errorText e = case e of MissingValue -> [ text "This field is required." ] BelowMin n -> [ text ("Value must be greater than or equals to " ++ String.fromFloat n ++ ".") ] AboveMax n -> [ text ("Value must be less than or equals to " ++ String.fromFloat n ++ ".") ] NotALength -> [ text "Invalid length value. Set an integer or a floating point number." ] NotAnInt -> [ text "Invalid integer value. This field only accepts integer value." ] descriptionId : Key -> String descriptionId key = Key.toString key ++ "_description" view : Model -> Parameters -> List (Html.Attribute InternalMsg) -> Html.Html Msg view model params attrs = let value : Key -> Html.Attribute InternalMsg value key = model.fields |> Dict.get (Key.toString key) |> Maybe.withDefault "" |> Html.Attributes.value in div attrs [ node "x-field" [] [ label [ for (Key.toString LugWidth), attribute "slot" "title" ] [ text "Lug width" ] , p [ id (descriptionId LugWidth), attribute "slot" "description" ] [ text "This will be the final width of your strap. " , text "You can use a size smaller than your lug width to create a play." ] , node "x-number-input" [] [ input (id (Key.toString LugWidth) :: step "1.0" :: value LugWidth :: onInput (FieldChanged LugWidth) :: ariaInvalid (not (Dict.get (Key.toString LugWidth) model.errors == Nothing)) :: ariaDescribedBy [ errorId LugWidth, descriptionId LugWidth ] :: lengthFieldAttrs constraints.lugWidth ) [] , span [ attribute "slot" "unit" ] [ text "mm" ] ] , p [ id (errorId LugWidth), attribute "slot" "error" ] (Dict.get (Key.toString LugWidth) model.errors |> Maybe.map errorText |> Maybe.withDefault [] ) ] , node "x-field" [] [ label [ for (Key.toString BuckleHoleCount), attribute "slot" "title" ] [ text "Hole Count" ] , p [ id (descriptionId BuckleHoleCount), attribute "slot" "description" ] [ text "Set 0 to disable buckle holes generation." ] , node "x-number-input" [] [ input (id (Key.toString BuckleHoleCount) :: step "1" :: value BuckleHoleCount :: onInput (FieldChanged BuckleHoleCount) :: ariaInvalid (not (Dict.get (Key.toString BuckleHoleCount) model.errors == Nothing)) :: ariaDescribedBy [ errorId BuckleHoleCount, descriptionId BuckleHoleCount ] :: intFieldAttrs constraints.longPiece.buckleHole.count ) [] ] , p [ id (errorId BuckleHoleCount), attribute "slot" "error" ] (Dict.get (Key.toString BuckleHoleCount) model.errors |> Maybe.map errorText |> Maybe.withDefault [] ) ] , node "x-field" [] [ label [ for (Key.toString BuckleHoleDiameter), attribute "slot" "title" ] [ text "Hole Diameter" ] , p [ id (descriptionId BuckleHoleDiameter), attribute "slot" "description" ] [ text "Diameter of buckle holes. You can leave the default value if you're going to use the center mark." ] , node "x-number-input" [] [ input (id (Key.toString BuckleHoleDiameter) :: step "1.0" :: value BuckleHoleDiameter :: onInput (FieldChanged BuckleHoleDiameter) :: disabled (params.longPiece.buckleHole.count == 0) :: ariaInvalid (not (Dict.get (Key.toString BuckleHoleDiameter) model.errors == Nothing)) :: ariaDescribedBy [ errorId BuckleHoleDiameter, descriptionId BuckleHoleDiameter ] :: lengthFieldAttrs constraints.longPiece.buckleHole.diameter ) [] , span [ attribute "slot" "unit" ] [ text "mm" ] ] , p [ id (errorId BuckleHoleDiameter), attribute "slot" "error" ] (Dict.get (Key.toString BuckleHoleDiameter) model.errors |> Maybe.map errorText |> Maybe.withDefault [] ) ] ] |> Html.map Internal
-
-
src/Parameters/Key.elm (new)
-
@@ -0,0 +1,29 @@-- Copyright 2026 Shota FUJI -- -- This Source Code Form is subject to the terms of the Mozilla Public -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. -- -- SPDX-License-Identifier: MPL-2.0 module Parameters.Key exposing (Key(..), toString) type Key = LugWidth | BuckleHoleCount | BuckleHoleDiameter toString : Key -> String toString key = case key of LugWidth -> "lug-width" BuckleHoleCount -> "buckle-hole-count" BuckleHoleDiameter -> "buckle-hole-diameter"
-
-
-
@@ -0,0 +1,112 @@-- Copyright 2026 Shota FUJI -- -- This Source Code Form is subject to the terms of the Mozilla Public -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at https://mozilla.org/MPL/2.0/. -- -- SPDX-License-Identifier: MPL-2.0 module Parameters.Parser exposing (Error(..), parseInt, parseLength) import Length exposing (Length, mm, toMM) import Parameters.Constraints as Constraints type Error = MissingValue | BelowMin Float | AboveMax Float | NotALength | NotAnInt parseInt : Constraints.NumberConstraints Int -> String -> Result Error Int parseInt constraints text = case text of "" -> Err MissingValue _ -> let min = constraints.min |> Maybe.map (\m -> Result.andThen (\x -> if x >= m then Ok x else Err (BelowMin (toFloat m)) ) ) |> Maybe.withDefault identity max = constraints.max |> Maybe.map (\m -> Result.andThen (\x -> if x <= m then Ok x else Err (AboveMax (toFloat m)) ) ) |> Maybe.withDefault identity in String.toInt text |> Result.fromMaybe NotAnInt |> min |> max parseLength : Constraints.NumberConstraints Length -> String -> Result Error Length parseLength constraints text = case text of "" -> Err MissingValue _ -> let min = constraints.min |> Maybe.map toMM |> Maybe.map (\m -> Result.andThen (\x -> if toMM x >= m then Ok x else Err (BelowMin m) ) ) |> Maybe.withDefault identity max = constraints.max |> Maybe.map toMM |> Maybe.map (\m -> Result.andThen (\x -> if toMM x <= m then Ok x else Err (AboveMax m) ) ) |> Maybe.withDefault identity in String.toFloat text |> Maybe.map mm |> Result.fromMaybe NotALength |> min |> max
-
-
-
@@ -7,9 +7,13 @@ //// SPDX-License-Identifier: MPL-2.0 import { XAppLayout } from "./x-app-layout.js"; import { XField } from "./x-field.js"; import { XNumberInput } from "./x-number-input.js"; import { XPanel } from "./x-panel.js"; import { XPreview } from "./x-preview.js"; customElements.define("x-app-layout", XAppLayout); customElements.define("x-field", XField); customElements.define("x-number-input", XNumberInput); customElements.define("x-panel", XPanel); customElements.define("x-preview", XPreview);
-
-
-
@@ -22,6 +22,7 @@ }::slotted([slot="parameters"]) { position: relative; width: min(20rem, 30vw); z-index: 100; }
-
-
src/elements/x-field.css (new)
-
@@ -0,0 +1,49 @@/* * Copyright 2026 Shota FUJI * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * SPDX-License-Identifier: MPL-2.0 */ :host { display: flex; flex-direction: column; gap: 0.1em; /* TODO: Let the consumer set spacing */ margin-top: 1em; } ::slotted([slot="title"]) { font-weight: bold; font-size: 0.9rem; } ::slotted(input) { border: 1px solid ButtonBorder; border-radius: 1px; } ::slotted(input[aria-invalid="true"]) { border-color: oklch(40% 0.88 2deg); } ::slotted([slot="description"]), ::slotted([slot="error"]) { margin: 0.5em 0; margin-top: 0.25em; font-size: 0.8rem; opacity: 0.9; } ::slotted([slot="error"]) { margin-bottom: 0; min-height: 1.5em; line-height: 1.5; color: red; opacity: 1; }
-
-
src/elements/x-field.js (new)
-
@@ -0,0 +1,38 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 import css from "./x-field.css"; export class XField extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: "open", }); const style = document.createElement("style"); style.textContent = css; shadow.appendChild(style); const title = document.createElement("slot"); title.name = "title"; shadow.appendChild(title); const description = document.createElement("slot"); description.name = "description"; shadow.appendChild(description); const slot = document.createElement("slot"); shadow.appendChild(slot); const error = document.createElement("slot"); error.name = "error"; shadow.appendChild(error); } }
-
-
-
@@ -0,0 +1,50 @@/* * Copyright 2026 Shota FUJI * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * SPDX-License-Identifier: MPL-2.0 */ :host { display: inline-flex; justify-content: start; align-items: baseline; gap: 0.25rem; border-bottom: 2px solid ButtonFace; cursor: text; } :host(:focus-within) { border-color: Highlight; } ::slotted(input) { font-size: 1.1rem; field-sizing: content; flex-shrink: 1; flex-grow: 0; border: none; background: transparent; @supports not (field-sizing: content) { width: 3em; } } ::slotted(input:focus), ::slotted(input:focus-visible) { outline: none; border: none; } ::slotted([slot="unit"]) { font-size: 0.8rem; flex-shrink: 0; flex-grow: 0; user-select: none; }
-
-
-
@@ -0,0 +1,112 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 import css from "./x-number-input.css"; export class XNumberInput extends HTMLElement { #slot = document.createElement("slot"); #input = null; constructor() { super(); const shadow = this.attachShadow({ mode: "open", }); const style = document.createElement("style"); style.textContent = css; shadow.appendChild(style); shadow.appendChild(this.#slot); const unit = document.createElement("slot"); unit.name = "unit"; shadow.appendChild(unit); } connectedCallback() { this.#slot.addEventListener("slotchange", this.#onSlotChange); this.addEventListener("click", this.#onClick); this.addEventListener("keydown", this.#onKeyDown); } disconnectedCallback() { this.#slot.removeEventListener("slotchange", this.#onSlotChange); this.removeEventListener("click", this.#onClick); this.removeEventListener("keydown", this.#onKeyDown); } #onSlotChange = () => { for (const child of this.#slot.assignedElements()) { if (child instanceof HTMLInputElement) { this.input = new WeakRef(child); return; } } }; #onClick = (event) => { if ((event.target && event.target instanceof HTMLInputElement) || !this.input) { return; } const input = this.input.deref(); if (!input) { this.input = null; return; } input.focus(); }; #onKeyDown = (event) => { if (!this.input) { return; } const input = this.input.deref(); if (!input) { this.input = null; return; } switch (event.key) { case "ArrowDown": case "ArrowUp": break; default: return; } const value = parseFloat(input.value); if (!Number.isFinite(value)) { return; } let step = parseFloat(input.step || "1"); if (!Number.isFinite(step)) { step = 1; } const amount = event.key === "ArrowDown" ? -step : step; const next = value + amount; const max = parseFloat(input.max); const min = parseFloat(input.min); input.value = Math.min( Number.isFinite(max) ? max : Infinity, Math.max(Number.isFinite(min) ? min : -Infinity, next), ).toString(10); // Tell Elm the updated value. input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); }; }
-