diff --git a/docs/changelog.lisp b/docs/changelog.lisp index d26dfba..15cb997 100644 --- a/docs/changelog.lisp +++ b/docs/changelog.lisp @@ -10,6 +10,22 @@ "REPL" "CSS" "HTTP")) + (0.4.0 2025-11-23 + " +## Added + +New input widgets were added: + +- textarea +- selectbox +- checkbox +- modal + +## Changed + +- button now accepts optional name, value and type arguments. + +") (0.3.0 2025-05-21 " ## Fixes diff --git a/reblocks-ui2-tailwind.asd b/reblocks-ui2-tailwind.asd index 2a1a021..bf887f6 100644 --- a/reblocks-ui2-tailwind.asd +++ b/reblocks-ui2-tailwind.asd @@ -17,6 +17,9 @@ "reblocks-ui2/containers/row/themes/tailwind" "reblocks-ui2/containers/column/themes/tailwind" "reblocks-ui2/inputs/text-input/themes/tailwind" + "reblocks-ui2/inputs/textarea/themes/tailwind" + "reblocks-ui2/inputs/select/themes/tailwind" + "reblocks-ui2/inputs/checkbox/themes/tailwind" "reblocks-ui2/themes/tailwind/padding" "reblocks-ui2/card/themes/tailwind" "reblocks-ui2/form/themes/tailwind" @@ -24,4 +27,5 @@ "reblocks-ui2/containers/controls-row/themes/tailwind" "reblocks-ui2/icon/themes/tailwind" "reblocks-ui2/buttons/themes/tailwind" - "reblocks-ui2/containers/tabs/themes/tailwind")) + "reblocks-ui2/containers/tabs/themes/tailwind" + "reblocks-ui2/modal/themes/tailwind")) diff --git a/reblocks-ui2.asd b/reblocks-ui2.asd index 46b7d08..99ee142 100644 --- a/reblocks-ui2.asd +++ b/reblocks-ui2.asd @@ -12,7 +12,9 @@ :depends-on ("reblocks-ui2/core" "reblocks-ui2/containers/stack" "reblocks-ui2/tables/clickable-row" + "reblocks-ui2/editable/string" "reblocks-ui2/html" + "reblocks-ui2/tables/search" ;; And we need to explicitly specify this dependency ;; to load all necessary components of Reblocks. ;; Otherwise possible some strange errors like: diff --git a/src/buttons/button.lisp b/src/buttons/button.lisp index a68b7b8..45dbacc 100644 --- a/src/buttons/button.lisp +++ b/src/buttons/button.lisp @@ -1,7 +1,6 @@ (uiop:define-package #:reblocks-ui2/buttons/button (:use #:cl) (:import-from #:reblocks/widget - #:get-html-tag #:create-widget-from #:widget #:defwidget) @@ -21,6 +20,8 @@ #:css-classes #:css-styles) (:import-from #:reblocks-ui2/widget + #:get-html-tag + #:html-attrs #:widget-height #:widget-width #:on-click @@ -31,6 +32,11 @@ (:import-from #:reblocks-ui2/utils/pin #:ensure-pin #:pin) + (:import-from #:reblocks-ui2/inputs/base + #:input-value + #:base-input-widget) + (:import-from #:reblocks-ui2/inputs/named + #:input-name) (:export #:button #:button-content #:button-class @@ -42,7 +48,7 @@ (in-package #:reblocks-ui2/buttons/button) -(defwidget button (ui-widget) +(defwidget button (base-input-widget) ((content :initarg :content :type widget :accessor button-content) @@ -64,17 +70,33 @@ (disabled :initarg :disabled :initform nil :type boolean - :accessor button-disabled)) + :accessor button-disabled) + (type :initarg :type + :initform nil + :type (or null string) + :accessor button-type + :documentation "If not given, then type will be \"button\" for a button having on-click callback or \"submit\" otherwise.")) (:default-initargs :width :min)) -(defun button (content &key (widget-class 'button) on-click (class "button") disabled style - (view :normal) - (size :l) - (pin :round) - (width :min) - height) +(defun button (content &key + name + value + type + (widget-class 'button) + on-click + (class "button") + disabled + style + (view :normal) + (size :l) + (pin :round) + (width :min) + height) (make-instance widget-class + :name name + :value value + :type type :content (create-widget-from content) :on-click on-click :class class @@ -88,36 +110,54 @@ (defmethod render ((widget button) (theme t)) + (render (button-content widget) + theme)) + + +(defmethod css-classes ((widget button) (theme t) &key) + (let ((view (if (button-disabled widget) + (get-disabled-button-view (button-view widget)) + (button-view widget)))) + (list + (button-class widget) + view + (widget-width widget) + (widget-height widget) + (button-size widget) + (button-pin widget) + ;; TODO: this is a Tailwind's property + ;; and we need to replace it with some object + ;; which will return css classes depending on theme. + "whitespace-nowrap"))) + + +(defmethod get-html-tag ((button button) (theme t)) + :button) + + +(defmethod html-attrs ((widget button) (theme t)) (let ((view (if (button-disabled widget) (get-disabled-button-view (button-view widget)) (button-view widget)))) - (with-html () - (:button :type (if (on-click widget) - ;; We need to set type to button for all buttons having - ;; having on-click handler to prevent the handler to be - ;; triggered when button is in the form and user hits - ;; Enter on some text-input field: - ;; https://stackoverflow.com/questions/62144665 - "button" - "submit") - :class (join-css-classes theme - (button-class widget) - view - (widget-width widget) - (widget-height widget) - (button-size widget) - (button-pin widget) - ;; TODO: this is a Tailwind's property - ;; and we need to replace it with some object - ;; which will return css classes depending on theme. - "whitespace-nowrap") - :style (join-css-styles (button-style widget) - (css-styles view - theme) - (css-styles (button-size widget) - theme) - (css-styles (button-pin widget) - theme)) - :disabled (button-disabled widget) - (render (button-content widget) - theme))))) + (list :type (cond + ((button-type widget) + (button-type widget)) + ((on-click widget) + ;; We need to set type to button for all buttons having + ;; having on-click handler to prevent the handler to be + ;; triggered when button is in the form and user hits + ;; Enter on some text-input field: + ;; https://stackoverflow.com/questions/62144665 + "button") + (t + "submit")) + :name (input-name widget) + :value (input-value widget) + :style (join-css-styles (button-style widget) + (css-styles view + theme) + (css-styles (button-size widget) + theme) + (css-styles (button-pin widget) + theme)) + :disabled (button-disabled widget)))) diff --git a/src/buttons/themes/tailwind.lisp b/src/buttons/themes/tailwind.lisp index 889598c..2280e4d 100644 --- a/src/buttons/themes/tailwind.lisp +++ b/src/buttons/themes/tailwind.lisp @@ -153,10 +153,3 @@ (:brick nil) (:round (add-size-suffix "rounded-r")) (:circle "rounded-r-full")))))) - - -;; Button's outer container -(defmethod css-classes ((button button) (theme tailwind-theme) &key) - (list - ;; To make it possible to add a button iside a text block. - "inline-block")) diff --git a/src/card.lisp b/src/card.lisp index a470a92..f8c3a3d 100644 --- a/src/card.lisp +++ b/src/card.lisp @@ -34,6 +34,8 @@ (:import-from #:reblocks-ui2/utils/align #:vertical-align #:horizontal-align) + (:import-from #:reblocks-ui2/utils/margin + #:margin) (:export #:card #:card-widget #:card-content @@ -71,7 +73,7 @@ (width "full") (height '(120 . nil)) (padding :l) - margin + (margin '(nil nil)) (horizontal-align :center) (vertical-align :center) on-click @@ -85,4 +87,4 @@ :horizontal-align (horizontal-align horizontal-align) :vertical-align (vertical-align vertical-align) :padding (padding padding) - :margin margin)) + :margin (margin margin))) diff --git a/src/containers/container.lisp b/src/containers/container.lisp index 533df01..708f398 100644 --- a/src/containers/container.lisp +++ b/src/containers/container.lisp @@ -47,19 +47,20 @@ (loop with collecting-subwidgets-p = t for item in subwidgets-and-options when (keywordp item) - do (setf collecting-subwidgets-p nil) + do (setf collecting-subwidgets-p nil) if collecting-subwidgets-p - collect item into subwidgets + collect item into subwidgets else - collect item into options + collect item into options finally (return - (destructuring-bind (&key (gap *default-gap*) - (column-type default-widget-class) - margin - (width :full) - height - on-click - css-classes) + (destructuring-bind (&key + (gap *default-gap*) + (column-type default-widget-class) + margin + (width :full) + height + on-click + css-classes) options (make-instance column-type :subwidgets (mapcar #'create-widget-from diff --git a/src/events.lisp b/src/events.lisp index 3ff0ac5..8879b9e 100644 --- a/src/events.lisp +++ b/src/events.lisp @@ -3,11 +3,12 @@ (:import-from #:event-emitter #:event-emitter) (:import-from #:reblocks/widget - #:defwidget - #:widget) + #:defwidget) + (:import-from #:reblocks-ui2/widget + #:ui-widget) (:export #:event-emitting-widget)) (in-package #:reblocks-ui2/events) -(defwidget event-emitting-widget (event-emitter widget) +(defwidget event-emitting-widget (event-emitter ui-widget) ()) diff --git a/src/form.lisp b/src/form.lisp index b282668..0a663f2 100644 --- a/src/form.lisp +++ b/src/form.lisp @@ -20,13 +20,17 @@ #:input-name #:named-input) (:import-from #:reblocks-ui2/utils/walk + #:children #:walk) (:import-from #:alexandria + #:hash-table-values #:make-keyword #:length=) (:import-from #:str #:downcase) (:import-from #:trivial-arguments) + (:import-from #:reblocks-ui2/events + #:event-emitting-widget) (:export #:form-widget #:form-content #:form-on-submit @@ -39,7 +43,7 @@ (log:info "Empty action was called with" args)) -(defwidget form-widget (ui-widget) +(defwidget form-widget (event-emitting-widget) ((content :initarg :content :reader form-content) (on-submit :initarg :on-submit @@ -104,24 +108,31 @@ (defun form (content &key (widget-class 'form-widget) on-submit) - (let ((widget (make-instance widget-class - :content content))) + (let ((form-widget (make-instance widget-class + :content content))) (when on-submit - (validate-on-submit-fun-args content on-submit) + ;; NOTE: Previously I've thought it is a good idea to validate + ;; argument names when form widget is created, but in general case + ;; all text-inputs can be known only after the form was rendered, + ;; because form content can use a generic HTML widget as it's content + ;; (validate-on-submit-fun-args content on-submit) - (setf (slot-value widget 'on-submit) - (make-on-submit-wrapper widget on-submit))) - (values widget))) + (setf (slot-value form-widget 'on-submit) + (make-on-submit-wrapper form-widget on-submit))) + (values form-widget))) -(defun make-on-submit-wrapper (widget on-submit-func) +(defun make-on-submit-wrapper (form-widget on-submit-func) "Makes an action handler which calls on-submit-func only with validated values of all form inputs" (flet ((on-submit (&rest form-data) (handler-case - (let ((validated-data (validate-form-data widget form-data))) - (apply on-submit-func widget validated-data)) + (let ((validated-data (validate-form-data form-widget form-data))) + (apply on-submit-func form-widget validated-data) + (event-emitter:emit :submit form-widget + form-widget + validated-data)) (validation-error () - (update widget))))) + (update form-widget))))) #'on-submit)) @@ -149,3 +160,7 @@ name) (setf (gethash name (form-inputs *current-form*)) widget))) + + +(defmethod children ((widget form-widget)) + (hash-table-values (form-inputs widget))) diff --git a/src/form/validation.lisp b/src/form/validation.lisp index da2718c..8ff16f0 100644 --- a/src/form/validation.lisp +++ b/src/form/validation.lisp @@ -26,6 +26,12 @@ (error-args condition))))) +(defun validation-error (message &rest args) + (error 'validation-error + :error-message message + :error-args args)) + + (define-condition form-validation-error (validation-error) ((num-errors :initarg :num-errors :reader num-errors))) diff --git a/src/inputs/checkbox.lisp b/src/inputs/checkbox.lisp new file mode 100644 index 0000000..4114e42 --- /dev/null +++ b/src/inputs/checkbox.lisp @@ -0,0 +1,62 @@ +(uiop:define-package #:reblocks-ui2/inputs/checkbox + (:use #:cl) + (:import-from #:reblocks/widget + #:defwidget) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks/dependencies + #:get-dependencies) + (:import-from #:reblocks-ui2/widget + #:ui-widget) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:normal + #:ensure-view) + (:import-from #:str + #:downcase) + (:import-from #:reblocks-ui2/inputs/base + #:base-input-widget) + (:export #:checkbox + #:checkbox-view + #:checkbox-size + #:checkbox-disabled + #:checkbox-checked)) +(in-package #:reblocks-ui2/inputs/checkbox) + + +(defwidget checkbox (base-input-widget) + ((view :type reblocks-ui2/inputs/text-input/view:input-view + :initarg :view + :initform (make-instance 'normal) + :reader checkbox-view) + (size :initarg :size + :type (member :s :m :l :xl) + :initform :m + :reader checkbox-size) + (disabled :initarg :disabled + :initform nil + :type boolean + :reader checkbox-disabled) + (checked :initarg :checked + :initform nil + :type boolean + :reader checkbox-checked)) + (:default-initargs :width nil)) + + +(defun checkbox (&key + (widget-class 'checkbox) + name + value + (view :normal) + (size :m) + disabled + checked + error) + (make-instance widget-class + :name (downcase name) + :value value + :view (ensure-view view) + :size size + :disabled disabled + :checked checked + :error error)) diff --git a/src/inputs/checkbox/themes/tailwind.lisp b/src/inputs/checkbox/themes/tailwind.lisp new file mode 100644 index 0000000..7dfcb87 --- /dev/null +++ b/src/inputs/checkbox/themes/tailwind.lisp @@ -0,0 +1,168 @@ +(uiop:define-package #:reblocks-ui2/inputs/checkbox/themes/tailwind + (:use #:cl) + (:import-from #:reblocks-ui2/widget + #:get-html-tag + #:render) + (:import-from #:reblocks-ui2/themes/tailwind + #:tailwind-theme) + (:import-from #:reblocks-ui2/inputs/base + #:input-name + #:input-value + #:input-error) + (:import-from #:reblocks-ui2/inputs/checkbox + #:checkbox-size + #:checkbox-disabled + #:checkbox-checked + #:checkbox-view + #:checkbox-placeholder + #:checkbox) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks-ui2/themes/styling + #:join-css-classes + #:css-classes + #:css-styles) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:normal + #:clear) + (:import-from #:reblocks-ui2/themes/color + #:color) + (:import-from #:anaphora + #:it + #:awhen)) +(in-package #:reblocks-ui2/inputs/checkbox/themes/tailwind) + + +(defparameter *checkbox-outer-block-classes* + (list "inline-block")) + + +(defparameter *checkbox-content-common-classes* + (list "flex")) + + +(defparameter *disabled-bg-color* + "bg-slate-200") + + +(defparameter *disabled-text-color* + "text-slate-400") + + +(defparameter *error-text-class* + "text-red-400") + + +(defparameter *error-border-class* + "border-red-400") + + +(defparameter *normal-border-color* + (color "border" + :light "gray-300" + :dark "gray-600" + :hover 2 + :focus 4)) + + +(defmethod get-html-tag ((widget checkbox) (theme tailwind-theme)) + :span) + + +(defmethod css-classes ((view normal) (theme tailwind-theme) &key invalid-state) + (list "border" + (cond + (invalid-state + *error-border-class*) + (t + *normal-border-color*)) + *checkbox-content-common-classes*)) + + +(defmethod css-classes ((view clear) (theme tailwind-theme) &key invalid-state) + (declare (ignore invalid-state)) + (list* "border-0" + *checkbox-content-common-classes*)) + + +(defmethod css-classes ((widget checkbox) (theme tailwind-theme) &key) + (list *checkbox-outer-block-classes* + (call-next-method))) + + +(defgeneric checkbox-font-size (theme size) + (:method ((theme tailwind-theme) (size t)) + "text-sm") + (:method ((theme tailwind-theme) (size (eql :l))) + "text-base") + (:method ((theme tailwind-theme) (size (eql :xl))) + "text-xl")) + + +(defgeneric checkbox-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "mx-1" + "my-0")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "mx-4" + "my-2"))) + + +(defgeneric additional-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "px-1")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "px-4"))) + + +(defmethod render ((widget checkbox) (theme tailwind-theme)) + (let ((invalid-state (not (null (input-error widget))))) + (with-html () + ;; Inner wrapper to + ;; (:div :class (join-css-classes theme + ;; "w-full" + ;; (checkbox-content-size-classes theme + ;; (checkbox-size widget)))) + (:input :class (join-css-classes theme + (checkbox-content-size-classes theme + (checkbox-size widget)) + "border-0" + "bg-transparent" + "focus:outline-none" + (checkbox-font-size theme (checkbox-size widget)) + (cond + ((checkbox-disabled widget) + *disabled-text-color*) + (t + (color "text" + :light "gray-900" + :dark "gray-100")))) + :name (input-name widget) + :value (input-value widget) + :type "checkbox" + :checked (checkbox-checked widget) + :disabled (checkbox-disabled widget) + :aria-invalid invalid-state + ;; If we don't set this to 1, then minumum input width + ;; will be more than 100px. More details are here: + ;; https://stackoverflow.com/a/29990524/70293 + :size 1) + + (when (input-error widget) + (:div :class (join-css-classes theme + "flex" + (additional-content-size-classes + theme + (checkbox-size widget))) + (:div :class *error-text-class* + (input-error widget))))))) diff --git a/src/inputs/select.lisp b/src/inputs/select.lisp new file mode 100644 index 0000000..b33fed3 --- /dev/null +++ b/src/inputs/select.lisp @@ -0,0 +1,118 @@ +(uiop:define-package #:reblocks-ui2/inputs/select + (:use #:cl) + (:import-from #:reblocks/widget + #:defwidget) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks/dependencies + #:get-dependencies) + (:import-from #:reblocks-ui2/widget + #:ui-widget) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:input-view + #:normal + #:ensure-view) + (:import-from #:str + #:downcase) + (:import-from #:reblocks-ui2/inputs/base + #:base-input-widget) + (:import-from #:serapeum + #:soft-list-of + #:->) + (:import-from #:reblocks-ui2/utils/pin + #:ensure-pin + #:pin) + (:import-from #:event-emitter + #:event-emitter) + (:export #:select + #:select-widget + #:select-disabled + #:select-size + #:select-view + #:select-pin + #:option + #:option-name + #:option-value + #:option-selected-p + #:ensure-option + #:select-options)) +(in-package #:reblocks-ui2/inputs/select) + + +(defclass option () + ((name :initarg :name + :type string + :reader option-name) + (value :initarg :value + :type string + :reader option-value) + (selected :initarg :selected + :type boolean + :initform nil + :reader option-selected-p))) + + +(-> option (string + &key + (:value (or null string)) + (:selected boolean)) + (values option &optional)) + +(defun option (name &key value selected) + (make-instance 'option + :name name + :value (or value + name) + :selected (when selected + t))) + + +(defwidget select (event-emitter base-input-widget) + ((options :initarg :options + :type (soft-list-of option) + :accessor select-options) + (view :type input-view + :initarg :view + :initform (make-instance 'normal) + :reader select-view) + (pin :initarg :pin + :type pin + :reader select-pin) + (size :initarg :size + :type (member :s :m :l :xl) + :initform :m + :reader select-size) + (disabled :initarg :disabled + :initform nil + :type boolean + :reader select-disabled))) + + +(-> ensure-option ((or string option)) + (values option &optional)) + +(defun ensure-option (value) + (etypecase value + (string (option value)) + (option value))) + + +(defun select (options + &key + (widget-class 'select) + name + (view :normal) + (pin :round) + (size :m) + disabled + validator + error) + (make-instance widget-class + :options (mapcar #'ensure-option options) + :name (downcase name) + :view (ensure-view view) + :pin (ensure-pin pin) + :size size + :disabled disabled + :validator validator + :error error)) diff --git a/src/inputs/select/themes/tailwind.lisp b/src/inputs/select/themes/tailwind.lisp new file mode 100644 index 0000000..26dd7b3 --- /dev/null +++ b/src/inputs/select/themes/tailwind.lisp @@ -0,0 +1,207 @@ +(uiop:define-package #:reblocks-ui2/inputs/select/themes/tailwind + (:use #:cl) + (:import-from #:parenscript) + (:import-from #:reblocks-ui2/widget + #:get-html-tag + #:render) + (:import-from #:reblocks-ui2/themes/tailwind + #:tailwind-theme) + (:import-from #:reblocks-ui2/inputs/base + #:input-name + #:input-value + #:input-error) + (:import-from #:reblocks-ui2/inputs/select + #:option-value + #:option-selected-p + #:option-name + #:select-options + #:select-type + #:select-size + #:select-disabled + #:select-view + #:select-pin + #:select) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks-ui2/themes/styling + #:join-css-classes + #:css-classes + #:css-styles) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:normal + #:clear) + (:import-from #:reblocks-ui2/themes/color + #:color) + (:import-from #:anaphora + #:it + #:awhen) + (:import-from #:reblocks/actions + #:make-js-action) + (:import-from #:reblocks/widgets/dom + #:dom-id) + (:import-from #:reblocks/page + #:find-widget-by-id) + (:import-from #:serapeum + #:dict)) +(in-package #:reblocks-ui2/inputs/select/themes/tailwind) + + +(defparameter *input-outer-block-classes* + (list "inline-block" + "w-full")) + + +(defparameter *input-content-common-classes* + (list "flex" + "w-full")) + + +(defparameter *disabled-bg-color* + "bg-slate-200") + + +(defparameter *disabled-text-color* + "text-slate-400") + + +(defparameter *error-text-class* + "text-red-400") + + +(defparameter *error-border-class* + "border-red-400") + + +(defparameter *normal-border-color* + (color "border" + :light "gray-300" + :dark "gray-600" + :hover 2 + :focus 4)) + + +(defmethod get-html-tag ((widget select) (theme tailwind-theme)) + :span) + +;; NOTE these are defined in text-input/themes/tailwind +;; (defmethod css-classes ((view normal) (theme tailwind-theme) &key invalid-state) +;; (list "border" +;; (cond +;; (invalid-state +;; *error-border-class*) +;; (t +;; *normal-border-color*)) +;; *input-content-common-classes*)) + + +;; (defmethod css-classes ((view clear) (theme tailwind-theme) &key invalid-state) +;; (declare (ignore invalid-state)) +;; (list* "border-0" +;; *input-content-common-classes*)) + + +(defmethod css-classes ((widget select) (theme tailwind-theme) &key) + (list *input-outer-block-classes* + (call-next-method))) + + +(defgeneric select-font-size (theme size) + (:method ((theme tailwind-theme) (size t)) + "text-sm") + (:method ((theme tailwind-theme) (size (eql :l))) + "text-base") + (:method ((theme tailwind-theme) (size (eql :xl))) + "text-xl")) + + +(defgeneric select-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "mx-1" + "my-0")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "mx-4" + "my-2"))) + + +(defgeneric additional-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "px-1")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "px-4"))) + + +(defun %on-change-handler (&key dom-id value &allow-other-keys) + (let ((widget (find-widget-by-id dom-id))) + (event-emitter:emit :on-change widget value))) + + +(defmethod render ((widget select) (theme tailwind-theme)) + (let ((invalid-state (not (null (input-error widget)))) + ;; "console.log(event.target.value)" + (on-change-action (when (event-emitter:listeners widget :on-change) + (make-js-action '%on-change-handler + :args + (dict "dom-id" + (dom-id widget) + "value" + '(ps:chain event target value)))))) + + (with-html () + ;; Outer wrapper + (:div :class (join-css-classes theme + "flex" + ;; "gap-1" + ;; Center items vertically to make left/right content in line with + ;; the main select. + "items-center" + (css-classes (select-pin widget) + theme + :size (select-size widget)) + (when (select-disabled widget) + *disabled-bg-color*) + (css-classes (select-view widget) + theme + :invalid-state invalid-state)) + + (:select + :class (join-css-classes theme + "w-full" + (select-content-size-classes theme + (select-size widget)) + "border-0" + "bg-transparent" + "focus:outline-none" + (select-font-size theme (select-size widget)) + (cond + ((select-disabled widget) + *disabled-text-color*) + (t + (color "text" + :light "gray-900" + :dark "gray-100")))) + :name (input-name widget) + :onchange on-change-action + :aria-invalid invalid-state + (loop for option in (select-options widget) + do (:option :value (option-value option) + :selected (option-selected-p option) + (option-name option))))) + + (when (input-error widget) + (:div :class (join-css-classes theme + "flex" + (additional-content-size-classes + theme + (select-size widget))) + (:div :class *error-text-class* + (input-error widget))))))) diff --git a/src/inputs/text-input.lisp b/src/inputs/text-input.lisp index 975f495..4ea4fd9 100644 --- a/src/inputs/text-input.lisp +++ b/src/inputs/text-input.lisp @@ -65,19 +65,19 @@ :accessor input-right-content))) -(defun input (&key (widget-class 'input-widget) - name - value - (type :text) - placeholder - (view :normal) - (pin :round) - (size :m) - disabled - validator - error - left-content - right-content) +(defun input (&key + (widget-class 'input-widget) + name + value + (type :text) + placeholder + (view :normal) + (pin :round) + (size :m) + disabled + validator + error + left-content right-content) (make-instance widget-class :name (downcase name) :type (downcase type) @@ -91,6 +91,3 @@ :error error :left-content left-content :right-content right-content)) - - - diff --git a/src/inputs/text-input/themes/tailwind.lisp b/src/inputs/text-input/themes/tailwind.lisp index 7643900..8dd3ae4 100644 --- a/src/inputs/text-input/themes/tailwind.lisp +++ b/src/inputs/text-input/themes/tailwind.lisp @@ -184,7 +184,7 @@ (awhen (input-right-content widget) (render it theme))) - + (when (input-error widget) (:div :class (join-css-classes theme "flex" diff --git a/src/inputs/textarea.lisp b/src/inputs/textarea.lisp new file mode 100644 index 0000000..df2b3d3 --- /dev/null +++ b/src/inputs/textarea.lisp @@ -0,0 +1,78 @@ +(uiop:define-package #:reblocks-ui2/inputs/textarea + (:use #:cl) + (:import-from #:reblocks/widget + #:defwidget) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks/dependencies + #:get-dependencies) + (:import-from #:reblocks-ui2/widget + #:ui-widget) + (:import-from #:reblocks-ui2/utils/pin + #:ensure-pin + #:pin) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:normal + #:ensure-view) + (:import-from #:str + #:downcase) + (:import-from #:reblocks-ui2/inputs/base + #:base-input-widget) + (:export #:textarea + #:textarea-type + #:textarea-placeholder + #:textarea-view + #:textarea-pin + #:textarea-size + #:textarea-disabled)) +(in-package #:reblocks-ui2/inputs/textarea) + + +(defwidget textarea (base-input-widget) + ((type :initform nil + :initarg :type + :type (or null string) + :reader textarea-type) + (placeholder :initform nil + :initarg :placeholder + :type (or null string) + :reader textarea-placeholder) + (view :type reblocks-ui2/inputs/text-input/view:input-view + :initarg :view + :initform (make-instance 'normal) + :reader textarea-view) + (pin :initarg :pin + :type pin + :reader textarea-pin) + (size :initarg :size + :type (member :s :m :l :xl) + :initform :m + :reader textarea-size) + (disabled :initarg :disabled + :initform nil + :type boolean + :reader textarea-disabled))) + + +(defun textarea (&key (widget-class 'textarea) + name + value + (type :text) + placeholder + (view :normal) + (pin :round) + (size :m) + disabled + validator + error) + (make-instance widget-class + :name (downcase name) + :type (downcase type) + :value value + :placeholder placeholder + :view (ensure-view view) + :pin (ensure-pin pin) + :size size + :disabled disabled + :validator validator + :error error)) diff --git a/src/inputs/textarea/themes/tailwind.lisp b/src/inputs/textarea/themes/tailwind.lisp new file mode 100644 index 0000000..e63370d --- /dev/null +++ b/src/inputs/textarea/themes/tailwind.lisp @@ -0,0 +1,182 @@ +(uiop:define-package #:reblocks-ui2/inputs/textarea/themes/tailwind + (:use #:cl) + (:import-from #:reblocks-ui2/widget + #:get-html-tag + #:render) + (:import-from #:reblocks-ui2/themes/tailwind + #:tailwind-theme) + (:import-from #:reblocks-ui2/inputs/base + #:input-name + #:input-value + #:input-error) + (:import-from #:reblocks-ui2/inputs/textarea + #:textarea + #:textarea-type + #:textarea-size + #:textarea-disabled + #:textarea-view + #:textarea-pin + #:textarea-placeholder + #:textarea-widget) + (:import-from #:reblocks/html + #:with-html) + (:import-from #:reblocks-ui2/themes/styling + #:join-css-classes + #:css-classes + #:css-styles) + (:import-from #:reblocks-ui2/inputs/text-input/view + #:normal + #:clear) + (:import-from #:reblocks-ui2/themes/color + #:color) + (:import-from #:anaphora + #:it + #:awhen)) +(in-package #:reblocks-ui2/inputs/textarea/themes/tailwind) + + +(defparameter *input-outer-block-classes* + (list "inline-block" + "w-full")) + + +(defparameter *input-content-common-classes* + (list "flex" + "w-full")) + + +(defparameter *disabled-bg-color* + "bg-slate-200") + + +(defparameter *disabled-text-color* + "text-slate-400") + + +(defparameter *error-text-class* + "text-red-400") + + +(defparameter *error-border-class* + "border-red-400") + + +(defparameter *normal-border-color* + (color "border" + :light "gray-300" + :dark "gray-600" + :hover 2 + :focus 4)) + + +(defmethod get-html-tag ((widget textarea) (theme tailwind-theme)) + :span) + + +(defmethod css-classes ((view normal) (theme tailwind-theme) &key invalid-state) + (list "border" + (cond + (invalid-state + *error-border-class*) + (t + *normal-border-color*)) + *input-content-common-classes*)) + + +(defmethod css-classes ((view clear) (theme tailwind-theme) &key invalid-state) + (declare (ignore invalid-state)) + (list* "border-0" + *input-content-common-classes*)) + + +(defmethod css-classes ((widget textarea) (theme tailwind-theme) &key) + (list *input-outer-block-classes* + (call-next-method))) + + +(defgeneric input-font-size (theme size) + (:method ((theme tailwind-theme) (size t)) + "text-sm") + (:method ((theme tailwind-theme) (size (eql :l))) + "text-base") + (:method ((theme tailwind-theme) (size (eql :xl))) + "text-xl")) + + +(defgeneric input-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "mx-1" + "my-0")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "mx-2" + "my-1")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "mx-4" + "my-2"))) + + +(defgeneric additional-content-size-classes (theme size) + (:method ((theme tailwind-theme) (size (eql :s))) + (list "px-1")) + (:method ((theme tailwind-theme) (size (eql :m))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :l))) + (list "px-2")) + (:method ((theme tailwind-theme) (size (eql :xl))) + (list "px-4"))) + + +(defmethod render ((widget textarea) (theme tailwind-theme)) + (let ((invalid-state (not (null (input-error widget))))) + (with-html () + ;; Outer wrapper + (:div :class (join-css-classes theme + "flex" + ;; "gap-1" + ;; Center items vertically to make left/right content in line with + ;; the main text-input. + "items-center" + (css-classes (textarea-pin widget) + theme + :size (textarea-size widget)) + (when (textarea-disabled widget) + *disabled-bg-color*) + (css-classes (textarea-view widget) + theme + :invalid-state invalid-state)) + + (:textarea :class (join-css-classes theme + "w-full" + (input-content-size-classes theme + (textarea-size widget)) + "border-0" + "bg-transparent" + "focus:outline-none" + (input-font-size theme (textarea-size widget)) + (cond + ((textarea-disabled widget) + *disabled-text-color*) + (t + (color "text" + :light "gray-900" + :dark "gray-100")))) + :name (input-name widget) + :aria-invalid invalid-state + ;; If we don't set this to 1, then minumum input width + ;; will be more than 100px. More details are here: + ;; https://stackoverflow.com/a/29990524/70293 + :size 1 + :placeholder (textarea-placeholder widget) + (input-value widget))) + + (when (input-error widget) + (:div :class (join-css-classes theme + "flex" + (additional-content-size-classes + theme + (textarea-size widget))) + (:div :class *error-text-class* + (input-error widget))))))) diff --git a/src/modal.lisp b/src/modal.lisp new file mode 100644 index 0000000..3a88125 --- /dev/null +++ b/src/modal.lisp @@ -0,0 +1,62 @@ +(uiop:define-package #:reblocks-ui2/modal + (:use #:cl) + (:import-from #:reblocks/widget + #:update + #:defwidget) + (:import-from #:reblocks-ui2/widget + #:ui-widget) + (:export #:modal + #:modal-header-content + #:modal-main-content + #:modal-visible-p + #:modal-show + #:modal-hide)) +(in-package #:reblocks-ui2/modal) + + +(defwidget modal (ui-widget) + ((header-content :initarg :header-content + :type (or null ui-widget) + :accessor modal-header-content) + (main-content :initarg :main-content + :type ui-widget + :accessor modal-main-content) + (visible :initarg :visible + :initform nil + :type boolean + :reader modal-visible-p)) + (:documentation "This widget shows a popup window.")) + + +(defun modal (content &key header-content visible) + (make-instance 'modal + :visible (when visible + t) + :main-content content + :header-content header-content)) + + +(defgeneric modal-show (widget &key content header-content) + (:documentation "Shows modal window.") + (:method ((widget modal) &key content header-content) + (unless (slot-value widget 'visible) + + (when content + (setf (modal-main-content widget) + content)) + + (when header-content + (setf (modal-header-content widget) + header-content)) + + (setf (slot-value widget 'visible) t) + (update widget)))) + + +(defgeneric modal-hide (widget) + (:documentation "Hides modal window.") + (:method ((widget modal)) + (when (slot-value widget 'visible) + (setf (slot-value widget 'visible) nil) + (update widget)))) + diff --git a/src/modal/themes/tailwind.lisp b/src/modal/themes/tailwind.lisp new file mode 100644 index 0000000..d1d6e08 --- /dev/null +++ b/src/modal/themes/tailwind.lisp @@ -0,0 +1,86 @@ +(uiop:define-package #:reblocks-ui2/modal/themes/tailwind + (:use #:cl) + (:import-from #:reblocks-ui2/widget + #:render) + (:import-from #:reblocks-ui2/modal + #:modal-hide + #:modal-header-content + #:modal-main-content + #:modal) + (:import-from #:reblocks-ui2/themes/tailwind + #:tailwind-theme) + (:import-from #:reblocks-ui2/html + #:html) + (:import-from #:reblocks/widgets/dom + #:dom-id) + (:import-from #:reblocks/actions + #:make-js-action) + (:import-from #:str + #:join) + (:import-from #:serapeum + #:fmt + #:dict) + (:import-from #:reblocks-ui2/buttons/button + #:button)) +(in-package #:reblocks-ui2/modal/themes/tailwind) + + +;; I tried this global function approach, +;; but sotimemes it stop work l;eading to this erors +;; [2025-10-02T18:50:40.542203Z] reblocks-ui2/modal/themes/tailwind tailwind.lisp (top level form.on-hide) Widget with dom-id = dom862 not found and can't be hidden +;; TODO: search for the reason +(defun on-hide (&key dom-id &allow-other-keys) + (unless dom-id + (error "HIDE-MODAL function should be called with dom-id argument.")) + (log:error "Hiding widget with" dom-id) + (let ((widget (reblocks/page:find-widget-by-id dom-id))) + (cond + (widget (modal-hide widget)) + (t + (log:error "Widget with dom-id = ~A not found and can't be hidden" dom-id)))) + (values)) + + +(defmethod render ((widget modal) (theme tailwind-theme)) + (flet ((hide (&key &allow-other-keys) + (modal-hide widget))) + (let ((header-content (modal-header-content widget)) + (main-content (modal-main-content widget)) + (main-content-classes (list "relative" "border-slate-200" "py-4" + "leading-normal" "text-slate-600" + "font-light")) + (hide-action (fmt "if( event.target === this) { ~A }" + (make-js-action #'hide) + ;; (make-js-action 'on-hide + ;; :args (dict "dom-id" (dom-id widget))) + )) + (hide-action-for-button + (make-js-action #'hide) + ;; (make-js-action 'on-hide + ;; :args (dict "dom-id" (dom-id widget))) + )) + (when header-content + (push "border-t" main-content-classes)) + + (html + ((:div :class "fixed inset-0 z-[999] grid h-screen w-screen place-items-center bg-black bg-opacity-60 backdrop-blur-sm transition-opacity duration-300" + :onclick hide-action + :tabindex "0" + (:div :class "relative m-4 p-4 w-2/5 min-w-[40%] max-w-[40%] rounded-lg bg-white shadow-sm transition-all duration-300 opacity-1 translate-y-0" + ;; Close button + (button "X" + :on-click hide-action-for-button + :style "position: absolute; top: 0; right: 0;") + + (when header-content + (:div :class "flex shrink-0 items-center pb-4 text-xl font-medium text-slate-800" + header-content)) + (:div :class (join " " main-content-classes) + main-content)))))))) + + +(defmethod reblocks-ui2/themes/styling:css-classes ((widget modal) (theme tailwind-theme) &key) + (if (reblocks-ui2/modal:modal-visible-p widget) + (call-next-method) + (list* "hidden" + (call-next-method)))) diff --git a/src/tables/search.lisp b/src/tables/search.lisp index eb14067..25147d9 100644 --- a/src/tables/search.lisp +++ b/src/tables/search.lisp @@ -3,7 +3,6 @@ (:import-from #:reblocks/widget #:update #:create-widget-from - #:render #:defwidget) (:import-from #:event-emitter #:on @@ -19,6 +18,9 @@ #:event-emitting-widget) (:import-from #:reblocks/dependencies #:get-dependencies) + (:import-from #:reblocks-ui2/widget + #:ui-widget + #:render) (:export #:make-search-widget #:search-widget @@ -30,7 +32,7 @@ (in-package #:reblocks-ui2/tables/search) -(defwidget search-widget () +(defwidget search-widget (ui-widget) ((filters :initarg :filters :type (or null event-emitter) :reader filters-widget) @@ -92,6 +94,9 @@ (flet ((on-filters-update (filters-widget &key (do-update t)) (multiple-value-bind (data next-page-getter) (funcall data-getter filters-widget) + ;; TODO: bind a global var to make function (current-search-widget) + ;; return it. This way it will be possible to make some actin buttons + ;; on each row. (setf (table-widget search-widget) (apply #'make-table columns data @@ -120,29 +125,31 @@ (update (table-widget search-widget)) (update controls-widget)))))) - (on :filters-update filters-widget - #'on-filters-update) - (on :load-more-data controls-widget - #'on-load-more) + (when filters-widget + (on :filters-update filters-widget + #'on-filters-update)) + (when controls-widget + (on :load-more-data controls-widget + #'on-load-more)) ;; Do the initial fill of the search results (on-filters-update filters-widget :do-update nil)) (values search-widget)))) -(defmethod render ((widget search-widget)) +(defmethod render ((widget search-widget) (theme t)) (when (filters-widget widget) - (render (filters-widget widget))) + (render (filters-widget widget) theme)) - (render (table-widget widget)) + (render (table-widget widget) theme) (when (controls-widget widget) - (render (controls-widget widget)))) + (render (controls-widget widget) theme))) -(defmethod render ((widget controls-widget)) +(defmethod render ((widget controls-widget) (theme t)) (when (show-button-p widget) - (render (next-page-button widget)))) + (render (next-page-button widget) theme))) (defmethod get-dependencies ((widget controls-widget)) diff --git a/src/tables/table.lisp b/src/tables/table.lisp index b98f491..18968d7 100644 --- a/src/tables/table.lisp +++ b/src/tables/table.lisp @@ -51,7 +51,7 @@ (defvar *current-cell*) -(defwidget table-row () +(defwidget table-row (ui-widget) ((cells :initarg :cells :type (soft-list-of widget) :reader row-cells) @@ -64,10 +64,12 @@ (defwidget table-widget (ui-widget) - ((columns :type (soft-list-of column) + ((columns :initarg :columns + :type (soft-list-of column) :initform nil :reader table-columns) - (rows :type (soft-list-of table-row) + (rows :initarg :rows + :type (soft-list-of table-row) :initform nil :reader table-rows) (row-class :initarg :row-class @@ -76,7 +78,7 @@ :reader table-row-class))) -(defwidget column () +(defwidget column (ui-widget) ((idx :initform nil :type (or null integer) :reader column-idx) diff --git a/src/tables/themes/tailwind/table.lisp b/src/tables/themes/tailwind/table.lisp index aa53f46..9778998 100644 --- a/src/tables/themes/tailwind/table.lisp +++ b/src/tables/themes/tailwind/table.lisp @@ -46,16 +46,19 @@ do (render row theme))))))) +(defmethod reblocks-ui2/widget:get-html-tag ((widget table-row) (theme tailwind-theme)) + :tr) + + (defmethod render ((widget table-row) (theme tailwind-theme)) (with-html () - (:tr - (loop with *current-row* = widget - for *current-column* in (table-columns - (row-table widget)) - for *current-cell* in (row-cells widget) - for classes = (join " " (column-css-classes *current-column* theme)) - do (:td :class classes - (render *current-cell* theme)))))) + (loop with *current-row* = widget + for *current-column* in (table-columns + (row-table widget)) + for *current-cell* in (row-cells widget) + for classes = (join " " (column-css-classes *current-column* theme)) + do (:td :class classes + (render *current-cell* theme))))) (defvar *default-header-cell-styles*