diff --git a/README.md b/README.md index 41d884c..a67488d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This package provides custom form fields for [Filament](https://filamentphp.com/ This package uses [LaravelLegends/pt-br-validator](https://github.com/LaravelLegends/pt-br-validator) to validate Brazilian Portuguese fields. -![image demo](https://raw.githubusercontent.com/leandrocfe/filament-ptbr-form-fields/develop/screenshots/v3-example.png) +![image demo](https://raw.githubusercontent.com/leandrocfe/filament-ptbr-form-fields/develop/screenshots/v3x-example.png) ## Installation @@ -81,46 +81,151 @@ use Leandrocfe\FilamentPtbrFormFields\PhoneNumber; PhoneNumber::make('phone_number') ``` -If you want to use a custom phone number format, use the format() method with a string argument representing the desired format: +If you want to use a custom phone number format, use the `mask() method with a string argument representing the desired format: ```php PhoneNumber::make('phone_number') -->format('99999-9999') + ->mask('(99) 99999-9999') ``` ```php PhoneNumber::make('phone_number') -->format('(+99)(99)99999-9999') + ->mask('+99 (99) 99999-9999') ``` ### Money -To create a money input with the Brazilian currency symbol as the prefix, use: +To create a money input field, use the following syntax: ```php use Leandrocfe\FilamentPtbrFormFields\Money; Money::make('price') + ->default('100,00') + +#output: 100.00 +``` + +This is suitable for use with `decimal` or `float` data types. + +#### Using Integer Values + +If you prefer to work with integer values, you can format the money input using the `intFormat()` method: + +```php +use Leandrocfe\FilamentPtbrFormFields\Money; +Money::make('price') + ->default(10000) + ->intFormat() + +#output: 10000 ``` +#### Getting the Raw State + +To retrieve the raw state of the field, you can use the `dehydratedMask() method: + +```php +use Leandrocfe\FilamentPtbrFormFields\Money; +Money::make('price') + ->default('100,00') + ->dehydrateMask() -If you want to remove the prefix, use the prefix() method with a null argument: +#output: 100,00 +``` +If you need to remove the prefix from the money input, simply pass null to the `prefix()` method: ```php Money::make('price') -->prefix(null) + ->prefix(null) ``` +#### Currency Formatting -By default, the mask is removed from the input when it is submitted. If you want to keep the mask, use the dehydrateMask() method with a false argument: +This package leverages the `archtechx/money` package under the hood. By default, it uses the `BRL` (Brazilian Real) format for currency values. +If you want to switch to the `USD` (United States Dollar) format, you can do so with the following code: ```php +use Leandrocfe\FilamentPtbrFormFields\Currencies\USD; + Money::make('price') -->dehydrateMask(false) + ->currency(USD::class) + ->prefix('$') ``` -The initial value of the input is '0,00'. If you want to change the initial value, use the initialValue() method with a string argument: +You can also define custom currencies to suit your specific needs: + ```php + +/* + * app/Currencies/EUR.php + */ + +declare(strict_types=1); + +namespace App\Currencies; + +use ArchTech\Money\Currency; + +class EUR extends Currency +{ + /* + * Code of the currency. + */ + public string $code = 'EUR'; + + /* + * Name of the currency. + */ + public string $name = 'Euro'; + + /* + * Rate of this currency relative to the default currency. + */ + public float $rate = 1.0; + + /* + * Number of decimals used in money calculations. + */ + public int $mathDecimals = 2; + + /* + * Number of decimals used in the formatted value + */ + public int $displayDecimals = 2; + + /* + * How many decimals of the currency's values should get rounded + */ + public int $rounding = 2; + + /* + * Prefix placed at the beginning of the formatted value. + */ + public string $prefix = '€'; + + /* + * The language code. + */ + public string $locale = 'pt'; + + /* + * The character used to separate the decimal values. + */ + public string $decimalSeparator = '.'; + + /* + * The character used to separate groups of thousands + */ + public string $thousandsSeparator = ','; +} + +``` + +```php +use App\Currencies\EUR; + Money::make('price') -->initialValue(null) +->currency(EUR::class) +->prefix('€') ``` ### Address diff --git a/composer.json b/composer.json index aede422..449bccf 100644 --- a/composer.json +++ b/composer.json @@ -16,10 +16,12 @@ } ], "require": { - "php": "^8.1 || ^8.2", + "php": "^8.1 || ^8.2 || ^8.3", + "archtechx/money": "^0.5.1", "filament/filament": "^3.0", "illuminate/contracts": "^10.0 || ^11.0", "laravellegends/pt-br-validator": "^10.0 || ^11.0", + "moneyphp/money": "^4.5", "spatie/laravel-package-tools": "^1.14.0" }, "require-dev": { diff --git a/dist/mask.min.js b/dist/mask.min.js deleted file mode 100644 index fc1a5a2..0000000 --- a/dist/mask.min.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{function h(n){n.directive("mask",(e,{value:t,expression:u},{effect:f,evaluateLater:c})=>{let r=()=>u,l="";queueMicrotask(()=>{if(["function","dynamic"].includes(t)){let o=c(u);f(()=>{r=a=>{let s;return n.dontAutoEvaluateFunctions(()=>{o(p=>{s=typeof p=="function"?p(a):p},{scope:{$input:a,$money:m.bind({el:e})}})}),s},i(e,!1)})}else i(e,!1);e._x_model&&e._x_model.set(e.value)}),e.addEventListener("input",()=>i(e)),e.addEventListener("blur",()=>i(e,!1));function i(o,a=!0){let s=o.value,p=r(s);if(!p||p==="false")return!1;if(l.length-o.value.length===1)return l=o.value;let g=()=>{l=o.value=d(s,p)};a?k(o,p,()=>{g()}):g()}function d(o,a){if(o==="")return"";let s=v(a,o);return b(a,s)}}).before("model")}function k(n,e,t){let u=n.selectionStart,f=n.value;t();let c=f.slice(0,u),r=b(e,v(e,c)).length;n.setSelectionRange(r,r)}function v(n,e){let t=e,u="",f={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/},c="";for(let r=0;r{let o="",a=0;for(let s=i.length-1;s>=0;s--)i[s]!==d&&(a===3?(o=i[s]+d+o,a=0):o=i[s]+o,a++);return o},c=n.startsWith("-")?"-":"",r=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),l=Array.from({length:r.split(e)[0].length}).fill("9").join("");return l=`${c}${f(l,t)}`,u>0&&n.includes(e)&&(l+=`${e}`+"9".repeat(u)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),l}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(h)});})(); diff --git a/resources/js/money.js b/resources/js/money.js new file mode 100644 index 0000000..86dd2ed --- /dev/null +++ b/resources/js/money.js @@ -0,0 +1,129 @@ +/** + * All credit goes to + * https://mary-ui.com + * https://github.com/lagden/currency + * + * It works perfect with some tweaks. + */ +class Currency { + unmaskedValue = 0 + + constructor(input, opts = {}) { + this.opts = { + keyEvent: 'input', + triggerOnBlur: false, + init: false, + backspace: false, + maskOpts: {}, + ...opts, + } + + if (input instanceof HTMLInputElement === false) { + throw new TypeError('The input should be a HTMLInputElement') + } + + // Add fraction on initial value if missing + const parts = String(input.value).split('.') + input.value = parts.length === 1 ? `${parts.shift()}.00` : `${parts.shift()}.${parts.pop().padEnd(2, '0')}` + + this.input = input + this.events = new Set() + + // Initialize + if (this.opts.init) { + this.input.value = Currency.masking(this.input.value, this.opts.maskOpts) + } + + // Listener + this.input.addEventListener(this.opts.keyEvent, this) + this.events.add(this.opts.keyEvent) + + this.input.addEventListener('click', this) + this.events.add('click') + + if (this.opts.triggerOnBlur) { + this.input.addEventListener('blur', this) + this.events.add('blur') + } + } + + static getUnmasked() { + return this.unmaskedValue + } + + static position(v) { + const nums = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']) + const len = v.length + + let cc = 0 + for (let i = len - 1; i >= 0; i--) { + if (nums.has(v[i])) { + break + } + cc++ + } + + return String(v).length - cc + } + + static masking(v, opts = {}) { + const { + empty = false, + locales = 'pt-BR', + options = { + minimumFractionDigits: 2, + }, + } = opts + + if (typeof v === 'number') { + v = v.toFixed(2) + } + + const n = String(v).replace(/\D/g, '').replace(/^0+/g, '') + const t = n.padStart(3, '0') + const d = t.slice(-2) + const i = t.slice(0, t.length - 2) + + if (empty && i === '0' && d === '00') { + return '' + } + + this.unmaskedValue = `${i}.${d}` + + return new Intl.NumberFormat(locales, options).format(this.unmaskedValue) + } + + onMasking(event) { + if (this.opts.backspace && event?.inputType === 'deleteContentBackward') { + return + } + + this.input.value = Currency.masking(this.input.value, this.opts.maskOpts) + const pos = Currency.position(this.input.value) + this.input.setSelectionRange(pos, pos) + } + + onClick() { + const pos = Currency.position(this.input.value) + this.input.focus() + this.input.setSelectionRange(pos, pos) + } + + destroy() { + for (const _event of this.events) { + this.input.removeEventListener(_event, this) + } + } + + handleEvent(event) { + if (event.type === 'click') { + this.onClick(event) + } else { + this.onMasking(event) + } + } +} + +if (!window.Currency) { + window.Currency = Currency +} diff --git a/screenshots/v3-example.png b/screenshots/v3-example.png deleted file mode 100644 index f799a81..0000000 Binary files a/screenshots/v3-example.png and /dev/null differ diff --git a/screenshots/v3x-example.png b/screenshots/v3x-example.png new file mode 100644 index 0000000..6ddc6e3 Binary files /dev/null and b/screenshots/v3x-example.png differ diff --git a/src/Currencies/BRL.php b/src/Currencies/BRL.php new file mode 100644 index 0000000..97eeb99 --- /dev/null +++ b/src/Currencies/BRL.php @@ -0,0 +1,30 @@ +currency() ->prefix('R$') - ->maxLength(13) - ->extraAlpineAttributes([ - - 'x-on:keypress' => 'function() { - var charCode = event.keyCode || event.which; - if (charCode < 48 || charCode > 57) { - event.preventDefault(); - return false; - } - return true; - }', - - 'x-on:keyup' => 'function() { - var money = $el.value; - money = money.replace(/\D/g, \'\'); - money = (parseFloat(money) / 100).toLocaleString(\'pt-BR\', { minimumFractionDigits: 2 }); - $el.value = money === \'NaN\' ? \'0,00\' : money; - }', - ]) - ->dehydrateMask() - ->default(0.00) - ->formatStateUsing(fn ($state) => $state ? number_format(floatval($state), 2, ',', '.') : $this->initialValue); + ->extraAlpineAttributes(fn () => $this->getOnKeyPress()) + ->extraAlpineAttributes(fn () => $this->getOnKeyUp()) + ->formatStateUsing(fn ($state) => $this->hydrateCurrency($state)) + ->dehydrateStateUsing(fn ($state) => $this->dehydrateCurrency($state)); + } + + public function initialValue(null|string|int|float|Closure $value = '0,00'): static + { + $this->initialValue = $value; + + return $this; } - public function dehydrateMask(bool|Closure $condition = true): static + public function currency(string|null|Closure $currency = BRL::class): static { - if ($condition) { - $this->dehydrateStateUsing(fn (?string $state): ?float => $this->convertToFloat($state)); - } else { - $this->dehydrateStateUsing(fn (?string $state): ?string => $this->convertToNumberFormat($state)); + $this->currency = new ($currency); + currencies()->add($currency); + + if ($currency !== 'BRL') { + $this->prefix(null); } return $this; } - public function initialValue(null|string|int|float|Closure $value = '0,00'): static + protected function hydrateCurrency($state): string { - $this->initialValue = $value; + $sanitized = $this->sanitizeState($state); + + $money = money(amount: $sanitized, currency: $this->getCurrency()); + + return $money->formatted(prefix: ''); + } + + protected function dehydrateCurrency($state): int|float|string + { + $sanitized = $this->sanitizeState($state); + $money = money(amount: $sanitized, currency: $this->getCurrency()); + + if ($this->getDehydrateMask()) { + return $money->formatted(); + } + + return $this->getIntFormat() ? $money->value() : $money->decimal(); + } + + public function dehydrateMask(bool $condition = true): static + { + $this->dehydrateMask = $condition; + + return $this; + } + + public function intFormat(bool|Closure $intFormat = true): static + { + $this->intFormat = $intFormat; return $this; } - private function sanitizeState(?string $state): ?Stringable + protected function sanitizeState(?string $state): ?int { $state = Str::of($state) ->replace('.', '') - ->replace(',', ''); + ->replace(',', '') + ->toInteger(); return $state ?? null; } - private function convertToFloat(Stringable|string|null $state): float + protected function getOnKeyPress(): array { - $state = $this->sanitizeState($state); - - if (! $state) { - return 0; - } + return [ + 'x-on:keypress' => 'function() { + var charCode = event.keyCode || event.which; + if (charCode < 48 || charCode > 57) { + event.preventDefault(); + return false; + } + return true; + }', + ]; + } - if ($state->length() > 2) { - $state = $state - ->substr(0, $state->length() - 2) - ->append('.') - ->append($state->substr($state->length() - 2, 2)); - } else { - $state = $state->prepend('0.'); - } + protected function getOnKeyUp(): array + { + $currency = new ($this->getCurrency()); + $numberFormatter = $currency->locale; + + return [ + 'x-on:keyup' => 'function() { + $el.value = Currency.masking($el.value, {locales:\''.$numberFormatter.'\'}); + }', + ]; + } - return floatval($state->toString()) ?? 0; + public function getCurrency(): ?Currency + { + return $this->currency; } - private function convertToNumberFormat(string $state): string + public function getDehydrateMask(): bool { - $state = $this->convertToFloat($state); + return $this->dehydrateMask; + } - return number_format($state, 2, ',', '.') ?? 0; + public function getIntFormat(): bool + { + return $this->intFormat; } }