diff --git a/externals/reflect-core b/externals/reflect-core index 64396b92..2537ba88 160000 --- a/externals/reflect-core +++ b/externals/reflect-core @@ -1 +1 @@ -Subproject commit 64396b92cec678703ba3cb8c898e6215affb8dd8 +Subproject commit 2537ba885ed5f6014bb416a6a9f587d0e316831b diff --git a/packages/builder-web-core/widgets-native/html-input/html-input-range.ts b/packages/builder-web-core/widgets-native/html-input/html-input-range.ts new file mode 100644 index 00000000..2482f243 --- /dev/null +++ b/packages/builder-web-core/widgets-native/html-input/html-input-range.ts @@ -0,0 +1,234 @@ +import type { CSSProperties, ElementCssStyleData } from "@coli.codes/css"; +import type { + Color, + ISliderManifest, + IWHStyleWidget, + SystemMouseCursors, +} from "@reflect-ui/core"; +import type { StylableJSXElementConfig } from "../../widget-core"; +import { WidgetKey } from "../../widget-key"; +import { Container } from "../container"; +import { JSX, JSXAttribute, NumericLiteral, StringLiteral } from "coli"; +import * as css from "@web-builder/styles"; +import { RoundSliderThumbShape } from "@reflect-ui/core/lib/slider.thumb"; + +/** + * A jsx attibute to indicate input type as range + */ +const attr_type_range = new JSXAttribute("type", new StringLiteral("range")); + +/** + * A html native input as a slider wit type="slider" + */ +export class HtmlInputRange extends Container implements ISliderManifest { + _type = "input/range"; + + // #region ISliderManifest + activeColor?: Color; + autoFocus: boolean; + divisions: number; + inactiveColor?: Color; + max: number; + min: number; + mouseCursor?: SystemMouseCursors; + thumbColor?: Color; + thumbShape?: RoundSliderThumbShape; + initialValue: number; + // #endregion ISliderManifest + + constructor({ + key, + + activeColor, + autoFocus, + divisions, + inactiveColor, + max, + min, + mouseCursor, + thumbColor, + thumbShape, + initialValue, + + ...rest + }: { + key: WidgetKey; + } & ISliderManifest & + IWHStyleWidget) { + super({ key, ...rest }); + + this.activeColor = activeColor; + this.autoFocus = autoFocus; + this.divisions = divisions; + this.inactiveColor = inactiveColor; + this.max = max; + this.min = min; + this.mouseCursor = mouseCursor; + this.thumbColor = thumbColor; + this.thumbShape = thumbShape; + this.initialValue = initialValue; + } + + styleData(): ElementCssStyleData { + const containerstyle = super.styleData(); + + // TODO: add styling + return { + // general layouts, continer --------------------- + ...containerstyle, + // ------------------------------------------------- + + /* Override default CSS styles */ + "-webkit-appearance": "none", + appearance: "none", + /* --------------------------- */ + + // general slider styles + // "background-color": , + ...css.background(this.background), + color: css.color(this.activeColor), + // outline: "none", + + opacity: 0.7, + /* 0.2 seconds transition on hover */ + "-webkit-transition": ".2s", + transition: "opacity .2s", + + // ---------------------- + + // thumb (knob) -------------------------------- + "::-webkit-slider-thumb": this.thumbShape + ? thumbstyle({ + ...this.thumbShape, + activeColor: this.activeColor, + platform: "webkit", + }) + : undefined, + "::-moz-range-thumb": this.thumbShape + ? thumbstyle({ + ...this.thumbShape, + activeColor: this.activeColor, + platform: "moz", + }) + : undefined, + // only works on firefox (also consider using "::-webkit-slider-runnable-track") + "::-moz-range-progress": { + "background-color": css.color(this.activeColor), + height: "100%", + }, + // --------------------------------------------- + + ":hover": { + /* Fully shown on mouse-over */ + opacity: 1, + }, + }; + } + + jsxConfig(): StylableJSXElementConfig { + const attrs = [ + attr_type_range, + this.autoFocus && new JSXAttribute("autofocus", new StringLiteral("on")), + // TODO: below attributes + // this.disabled && new JSXAttribute("disabled"), + // this.readOnly && new JSXAttribute("readonly"), + this.initialValue && + new JSXAttribute( + "value", + new StringLiteral(this.initialValue.toString()) + ), + this.divisions && + new JSXAttribute("step", new StringLiteral(this.divisions.toString())), + this.min && + new JSXAttribute("min", new StringLiteral(this.min.toString())), + this.max && + new JSXAttribute("max", new StringLiteral(this.max.toString())), + ].filter(Boolean); + + return { + type: "tag-and-attr", + tag: JSX.identifier("input"), + attributes: attrs, + }; + } + + get finalStyle() { + const superstyl = super.finalStyle; + + // width override. ------------------------------------------------------------------------------------------ + // input element's width needs to be specified if the position is absolute and the left & right is specified. + let width = superstyl.width; + if ( + width === undefined && + superstyl.position === "absolute" && + superstyl.left !== undefined && + superstyl.right !== undefined + ) { + width = "calc(100% - " + superstyl.left + " - " + superstyl.right + ")"; + } + // ---------------------------------------------------------------------------------------------------------- + + return { + ...superstyl, + width, + }; + } +} + +export class HtmlSlider extends HtmlInputRange {} + +function thumbstyle({ + enabledThumbRadius, + activeColor, + platform, +}: RoundSliderThumbShape & { + platform: "moz" | "webkit"; +} & { + activeColor: Color; +}): CSSProperties { + const base: CSSProperties = { + width: css.px(enabledThumbRadius), + height: css.px(enabledThumbRadius), + "border-radius": "50%", + "background-color": css.color(activeColor), + // ..._aberation_support_progress_fill_dirty({ color: activeColor }), + cursor: "pointer" /* Cursor on hover */, + }; + switch (platform) { + case "moz": { + // no overrides + return base; + } + case "webkit": { + return { + "-webkit-appearance": "none" /* Override default look */, + appearance: "none", + ...base, + }; + } + } + + return base; +} + +/** + * There are no proper way to handle the progress color in Chome, Safari. + * Moz supports the `-moz-range-progress`, `-moz-range-track`, but this is [not standard](https://developer.mozilla.org/en-US/docs/Web/CSS/::-moz-range-progress) + * + * We use this trick instead. + * https://codepen.io/okayoon/pen/PMpmjp + * + * this only works with overflow: hidden, so we cannot show overflowing round thumb. + * + * we can also do it by using linear-gradient, [but this can't be done only by using css](https://codepen.io/duplich/pen/qjYQEZ). + * + * @returns + */ +function _aberation_support_progress_fill_dirty({ color }: { color: Color }) { + if (color === undefined) { + return; + } + return { + "box-shadow": `-100vw 0 0 100vw ${css.color(color)}`, + }; +} diff --git a/packages/builder-web-core/widgets-native/html-input/html-input-text.ts b/packages/builder-web-core/widgets-native/html-input/html-input-text.ts index bd620c78..011734eb 100644 --- a/packages/builder-web-core/widgets-native/html-input/html-input-text.ts +++ b/packages/builder-web-core/widgets-native/html-input/html-input-text.ts @@ -114,9 +114,10 @@ export class HtmlInputText extends Container implements ITextFieldManifest { } styleData(): ElementCssStyleData { + const containerstyle = super.styleData(); + // TODO: // - support placeholder text color styling - const containerstyle = super.styleData(); return { // general layouts, continer --------------------- diff --git a/packages/builder-web-core/widgets-native/html-input/index.ts b/packages/builder-web-core/widgets-native/html-input/index.ts index e5354d92..2413a60f 100644 --- a/packages/builder-web-core/widgets-native/html-input/index.ts +++ b/packages/builder-web-core/widgets-native/html-input/index.ts @@ -1 +1,2 @@ export { HtmlTextField as TextInput } from "./html-input-text"; +export { HtmlSlider as Slider } from "./html-input-range"; diff --git a/packages/designto-token/support-flags/index.ts b/packages/designto-token/support-flags/index.ts index 488627d9..a77baf43 100644 --- a/packages/designto-token/support-flags/index.ts +++ b/packages/designto-token/support-flags/index.ts @@ -15,6 +15,7 @@ import { tokenize_flagged_wrap } from "./token-wrap"; import { tokenize_flagged_wh_declaration } from "./token-wh"; import { tokenize_flagged_fix_wh } from "./token-wh-fix"; import { tokenize_flagged_button } from "./token-button"; +import { tokenize_flagged_slider } from "./token-slider"; export default function handleWithFlags(node: ReflectSceneNode) { const flags = parse(node.name); @@ -94,4 +95,8 @@ function _handle_with_flags(node, flags: FlagsParseResult) { if (flags.__meta?.contains_input_flag) { return tokenize_flagged_textfield(node, flags[keys.flag_key__as_input]); } + + if (flags.__meta?.contains_slider_flag) { + return tokenize_flagged_slider(node, flags[keys.flag_key__as_slider]); + } } diff --git a/packages/designto-token/support-flags/token-slider/index.ts b/packages/designto-token/support-flags/token-slider/index.ts new file mode 100644 index 00000000..1f7960e2 --- /dev/null +++ b/packages/designto-token/support-flags/token-slider/index.ts @@ -0,0 +1,172 @@ +import { Slider, Container, Colors } from "@reflect-ui/core"; +import type { AsSliderFlag } from "@code-features/flags/types"; +import type { + ReflectEllipseNode, + ReflectFrameNode, + ReflectRectangleNode, + ReflectSceneNode, + ReflectTextNode, +} from "@design-sdk/figma-node"; +import { keyFromNode } from "../../key"; +import assert from "assert"; +import { tokenizeLayout } from "../../token-layout"; +import { paintToColor } from "@design-sdk/core/utils/colors"; +import { unwrappedChild } from "../../wrappings"; +import { RoundSliderThumbShape } from "@reflect-ui/core/lib/slider.thumb"; + +/** + * + * from + * ``` + * row|col[ + * enabled text, + * other elements, + * ] + * ``` + * + * to + * ``` + * input { + * enabled text + * } + * ``` + */ +export function tokenize_flagged_slider( + node: ReflectSceneNode, + flag: AsSliderFlag +): Slider | Container { + if (flag.value === false) return; + + const validated = validate_slider(node); + if (validated.error === false) { + const _key = keyFromNode(node); + + switch (validated.__type) { + case "frame-as-slider": { + const { slider_root, thumb, value } = validated; + + // initial value ----------------------------- + const p_w = slider_root.width; + const v_w = value?.width ?? 0; + // calculate percentage of value by its width, round to 2 decimal point + const _initial_value = + Math.round((v_w / p_w + Number.EPSILON) * 100) / 100; + // ------------------------------------------- + + // active color ------------------------------ + // TODO: use theme color as default if non available + const _activecolor = value?.primaryColor ?? Colors.blue; + // ------------------------------------------- + + // thumb style ------------------------------- + const _thumbcolor = thumb?.primaryColor ?? Colors.blue; + // currently only round thumb is supported + const _thumbsize = + Math.max(thumb?.height ?? 0, thumb?.width ?? 0) ?? undefined; + const _thumbelevation = thumb?.primaryShadow?.blurRadius ?? undefined; + // ------------------------------------------- + + const container = unwrappedChild( + tokenizeLayout.fromFrame( + slider_root, + slider_root.children, + { is_root: false }, + {} + ) + ) as Container; + + // @ts-ignore FIXME: no tsignore + return new Container({ + ...container, + child: new Slider({ + key: _key, + ...container, + activeColor: _activecolor, + divisions: 0.01, // when min - max is 0 - 1 + thumbColor: _thumbcolor, + thumbShape: new RoundSliderThumbShape({ + elevation: _thumbelevation, + enabledThumbRadius: _thumbsize, + }), + initialValue: _initial_value, + }), + }); + } + default: + throw new Error( + `unexpected type while handling slider flag ${validated.__type}` + ); + } + } else { + throw new Error(validated.error); + } +} + +type ThumbAcceptableNode = + | ReflectFrameNode + | ReflectRectangleNode + | ReflectEllipseNode; + +type ValueIndicatorAcceptableNode = ReflectFrameNode | ReflectRectangleNode; + +/** + * validate if layer casted as input can be actually tokenized to input. + * + * - when applyed to frame, + * 1. the root should be a flex + * 2. the children should be a valid text node + * + * - when applyed to text, + * 1. the text should be visible + * 2. the text should be not empty + * @param input + */ +function validate_slider(node: ReflectSceneNode): + | { + __type: "frame-as-slider"; + error: false; + slider_root: ReflectFrameNode; + thumb: ThumbAcceptableNode; + value?: ValueIndicatorAcceptableNode; + } + | { error: string } { + assert(!!node, "target must not be null or undefined"); + switch (node.type) { + case "FRAME": { + // find the root of slider + // find the value of the slider + // find the thumb of the slider + const root = node; + + const thumb = node.grandchildren.find((n) => { + // TODO: move this to detection + const _0 = + n.type === "FRAME" || n.type === "RECTANGLE" || n.type === "ELLIPSE"; + const _1 = n.width > 0 && n.height > 0; + const _2 = n.width <= 40 && n.height <= 40; + const _3 = n.width === n.height; + return _0 && _1 && _2 && _3; + }); + + const value = node.grandchildren + .filter((n) => n.id !== thumb?.id) + .find((n) => { + const _0 = n.type === "FRAME" || n.type === "RECTANGLE"; + const _1 = n.width > 0 && n.height > 0; + const _2 = n.height === root.height; + const _3 = n.primaryColor !== root.primaryColor; + return _0 && _1 && _2 && _3; + }); + + return { + __type: "frame-as-slider", + slider_root: root, + thumb: thumb as ThumbAcceptableNode, + value: value as ValueIndicatorAcceptableNode, + error: false, + }; + } + default: + return { error: "input target is not a valid frame or a text node" }; + } +} diff --git a/packages/designto-web/tokens-to-web-widget/compose-unwrapped-slider.ts b/packages/designto-web/tokens-to-web-widget/compose-unwrapped-slider.ts new file mode 100644 index 00000000..21ee6990 --- /dev/null +++ b/packages/designto-web/tokens-to-web-widget/compose-unwrapped-slider.ts @@ -0,0 +1,10 @@ +import * as web from "@web-builder/core"; +import * as core from "@reflect-ui/core"; + +export function compose_unwrapped_slider( + key, + widget: core.Slider, + container?: core.Container +): web.Slider { + return new web.Slider({ ...(container ?? {}), ...widget, key }); +} diff --git a/packages/designto-web/tokens-to-web-widget/index.ts b/packages/designto-web/tokens-to-web-widget/index.ts index 3289cc65..a60787d6 100644 --- a/packages/designto-web/tokens-to-web-widget/index.ts +++ b/packages/designto-web/tokens-to-web-widget/index.ts @@ -16,6 +16,7 @@ import { compose_wrapped_with_sized_box } from "./compose-wrapped-with-sized-box import { compose_wrapped_with_overflow_box } from "./compose-wrapped-with-overflow-box"; import { compose_unwrapped_text_input } from "./compose-unwrapped-text-field"; import { compose_unwrapped_button } from "./compose-unwrapped-button"; +import { compose_unwrapped_slider } from "./compose-unwrapped-slider"; import { compose_instanciation } from "./compose-instanciation"; import { IWHStyleWidget } from "@reflect-ui/core"; import * as reusable from "@code-features/component/tokens"; @@ -227,6 +228,8 @@ function compose( // textfield else if (widget instanceof core.TextField) { thisWebWidget = compose_unwrapped_text_input(_key, widget); + } else if (widget instanceof core.Slider) { + thisWebWidget = compose_unwrapped_slider(_key, widget); } // #endregion @@ -245,12 +248,16 @@ function compose( container.background = widget.background; thisWebWidget = container; + // #region // mergable widgets for web if (widget.child instanceof core.TextField) { thisWebWidget = compose_unwrapped_text_input(_key, widget.child, widget); } else if (widget.child instanceof core.ButtonStyleButton) { thisWebWidget = compose_unwrapped_button(_key, widget.child, widget); + } else if (widget.child instanceof core.Slider) { + thisWebWidget = compose_unwrapped_slider(_key, widget.child, widget); } + // #endregion } // ------------------------------------- diff --git a/packages/support-flags/--as-slider/index.ts b/packages/support-flags/--as-slider/index.ts new file mode 100644 index 00000000..25ff30af --- /dev/null +++ b/packages/support-flags/--as-slider/index.ts @@ -0,0 +1,16 @@ +// priamry +export const flag_key__as_slider = "as-slider"; +// alias +const flag_key__as_range = "as-range"; + +export const flag_key_alias__as_slider = [ + flag_key__as_slider, + flag_key__as_range, +]; + +export interface AsSliderFlag { + flag: typeof flag_key__as_slider | typeof flag_key__as_range; + + value: boolean; + _raw?: string; +} diff --git a/packages/support-flags/keys.ts b/packages/support-flags/keys.ts index decf545b..c13ae5b5 100644 --- a/packages/support-flags/keys.ts +++ b/packages/support-flags/keys.ts @@ -8,8 +8,8 @@ import { flag_key_alias__as_p, flag_key__as_p } from "./--as-p"; import { flag_key_alias__as_span, flag_key__as_span } from "./--as-span"; import { flag_key__as_button, flag_key_alias__as_button } from "./--as-button"; - import { flag_key__as_input, flag_key_alias_as_input } from "./--as-input"; +import { flag_key__as_slider, flag_key_alias__as_slider } from "./--as-slider"; import { flag_key_alias__width, flag_key__width } from "./--width"; import { flag_key_alias__min_width, flag_key__min_width } from "./--min-width"; @@ -42,6 +42,8 @@ export { flag_key__as_button }; export { flag_key__as_input }; +export { flag_key__as_slider }; + export { flag_key__width, flag_key__min_width, @@ -65,6 +67,7 @@ export const alias = { as_span: flag_key_alias__as_span, as_button: flag_key_alias__as_button, as_input: flag_key_alias_as_input, + as_slider: flag_key_alias__as_slider, width: flag_key_alias__width, min_width: flag_key_alias__min_width, max_width: flag_key_alias__max_width, diff --git a/packages/support-flags/parse.ts b/packages/support-flags/parse.ts index 2a89f20a..22b74fb7 100644 --- a/packages/support-flags/parse.ts +++ b/packages/support-flags/parse.ts @@ -20,6 +20,7 @@ import type { FixWHFlag, DeclareSpecificationFlag, WHDeclarationFlag, + AsSliderFlag, } from "./types"; export type FlagsParseResult = Results & { @@ -29,6 +30,7 @@ export type FlagsParseResult = Results & { contains_span_flag: boolean; contains_button_flag: boolean; contains_input_flag: boolean; + contains_slider_flag: boolean; contains_wh_declaration_flag: boolean; contains_fix_wh_flag: boolean; contains_declare_flag: boolean; @@ -65,6 +67,9 @@ export function parse(name: string): FlagsParseResult { // input __input_alias_pref, + // slider + __slider_alias_pref, + //#region __width_alias_pref, __max_width_alias_pref, @@ -110,6 +115,11 @@ export function parse(name: string): FlagsParseResult { keys.alias.as_input ); + const as_slider_flag = handle_single_boolean_flag_alias( + _raw_parsed, + keys.alias.as_slider + ); + const wh_declaration_flag = transform_wh_declaration_alias_from_raw(_raw_parsed); const fix_wh_flag = handle_single_boolean_flag_alias( @@ -139,6 +149,7 @@ export function parse(name: string): FlagsParseResult { contains_span_flag: notempty(as_span_flag), contains_button_flag: notempty(as_button_flag), contains_input_flag: notempty(as_input_flag), + contains_slider_flag: notempty(as_slider_flag), contains_wh_declaration_flag: notempty(as_span_flag), contains_fix_wh_flag: notempty(fix_wh_flag), contains_declare_flag: notempty(declare_flag), @@ -274,6 +285,10 @@ const __input_alias_pref = _simple_boolean_value_flag_prefernce_mapper( keys.alias.as_input ); +const __slider_alias_pref = _simple_boolean_value_flag_prefernce_mapper( + keys.alias.as_slider +); + // ----------------------------------------------------------------------------- // ----------------------------------------------------------------------------- // ----------------------------------------------------------------------------- diff --git a/packages/support-flags/types.ts b/packages/support-flags/types.ts index bde8ece8..756ce2a8 100644 --- a/packages/support-flags/types.ts +++ b/packages/support-flags/types.ts @@ -7,6 +7,7 @@ import type { AsHeading5Flag } from "./--as-h5"; import type { AsHeading6Flag } from "./--as-h6"; import type { AsButtonFlag } from "./--as-button"; import type { AsInputFlag } from "./--as-input"; +import type { AsSliderFlag } from "./--as-slider"; import type { AsParagraphFlag } from "./--as-p"; import type { AsTextSpanFlag } from "./--as-span"; import type { WidthFlag } from "./--width"; @@ -105,11 +106,8 @@ export type { AsTextSpanFlag }; // --------------------------------------------------------------------------- export type { AsButtonFlag }; - -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- - export type { AsInputFlag }; +export type { AsSliderFlag }; // --------------------------------------------------------------------------- // ---------------------------------------------------------------------------