Skip to content

Instantly share code, notes, and snippets.

@zaru
Last active September 28, 2021 01:15
Show Gist options
  • Save zaru/0b294cea3a55687c3c8e0b5df070d488 to your computer and use it in GitHub Desktop.
Save zaru/0b294cea3a55687c3c8e0b5df070d488 to your computer and use it in GitHub Desktop.
Stimulus example ( switch tabs / custom input / form sync value / editable label / form validator )
<?php
sleep(1);
http_response_code(500);
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="https://unpkg.com/stimulus/dist/stimulus.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<link href="style.css" media="all" rel="stylesheet">
<script>
(() => {
_.mixin({
memoizeDebounce: function(func, wait=0) {
var mem = _.memoize(function() {
return _.debounce(func, wait)
});
return function(){mem.apply(this, arguments).apply(this, arguments)}
}
});
const application = Stimulus.Application.start();
application.register('validator', class extends Stimulus.Controller {
static targets = ['fields', 'errors', 'submit', 'dependentFields'];
static classes = ['error'];
errors = {};
initialize() {
this.validate = _.memoizeDebounce(this.validate, 250);
}
connect() {
this.fieldsTargets.forEach((field) => {
const error = this.checkRule(field);
if (error) {
this.disableSubmit();
}
});
this.checkDependsOn();
this.errorsTargets.forEach((error) => {
error.style.display = 'none';
});
}
async validate(event) {
const field = event.target;
const useServerValidation = !!field.dataset.validateEndpoint;
let error = useServerValidation ? await this.checkRuleByServer(event.target, true) : '';
if (!error) {
error = this.checkRule(event.target, true);
}
if (error) {
this.errors[field.name] = error;
} else {
delete this.errors[field.name];
}
const errorElement = this.errorsTargets.find((error) => error.dataset.fieldName === event.target.name);
if (error) {
errorElement.textContent = error;
errorElement.style.display = 'inline-block';
this.disableSubmit();
} else {
errorElement.textContent = '';
errorElement.style.display = 'none';
if (Object.keys(this.errors).length === 0) {
this.enableSubmit();
}
}
this.checkDependsOn();
}
// 同名の checkbox 必須チェックをするために、最低1つでもチェックがつけば他の checkbox の必須確認をしないようにする
syncCheckbox(event) {
const name = event.currentTarget.name;
const checkboxGroups = this.fieldsTargets.filter((field) => field.name === name);
const checked = checkboxGroups.find((checkbox) => checkbox.checked);
if (checked) {
checkboxGroups.forEach((checkbox) => {
checkbox.required = false;
});
} else {
checkboxGroups.forEach((checkbox) => {
checkbox.required = true;
});
}
}
// 「xxx がチェックされていたら必須項目になる」といった依存構造の制御を行う
// 現時点では依存先が type="radio" のみ対応
checkDependsOn() {
this.dependentFieldsTargets.forEach((field) => {
const [dependsOnFieldName, value] = field.dataset.dependsOn.split('#');
const dependsOnField = this.fieldsTargets.find((field) => field.name === dependsOnFieldName && field.checked);
if (dependsOnField.value === value) {
field.required = true;
const error = this.checkRule(field);
if (error) {
this.disableSubmit();
}
} else {
field.required = false;
delete this.errors[field.name];
if (Object.keys(this.errors).length === 0) {
this.enableSubmit();
}
}
});
}
checkRule(field, showError = false) {
const valid = field.checkValidity();
if (showError && !valid) {
field.classList.add(this.errorClass);
} else {
field.classList.remove(this.errorClass);
}
return field.validationMessage;
}
async checkRuleByServer(field, showError = false) {
const endpoint = field.dataset.validateEndpoint;
const data = {
[field.name]: field.value
};
const result = await fetch(endpoint,{
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const json = await result.json();
if (showError && json.result !== 'ok') {
field.classList.add(this.errorClass);
} else {
field.classList.remove(this.errorClass);
}
return json.error_message;
}
disableSubmit() {
this.submitTarget.disabled = true;
}
enableSubmit() {
this.submitTarget.disabled = false;
}
});
})();
</script>
</head>
<body>
<h1>Form validation</h1>
<form data-controller="validator"
data-validator-error-class="error">
<dl>
<dt>name (required)</dt>
<dd>
<input type="text"
name="name"
data-validator-target="fields"
data-action="keyup->validator#validate"
required>
<span data-validator-target="errors" data-field-name="name"></span>
</dd>
<dt>number (required / between 1 and 10)</dt>
<dd>
<input type="number"
name="number"
data-validator-target="fields"
data-action="keyup->validator#validate"
min="1"
max="10"
required>
<span data-validator-target="errors" data-field-name="number"></span>
</dd>
<dt>email (required / email format)</dt>
<dd>
<input type="email"
name="email"
data-validator-target="fields"
data-action="keyup->validator#validate"
required>
<span data-validator-target="errors" data-field-name="email"></span>
</dd>
<dt>foo,bar,baz (required / pattern /^(foo|bar|baz)$/)</dt>
<dd>
<input type="text"
name="foobarbaz"
data-validator-target="fields"
data-action="keyup->validator#validate"
pattern="^(foo|bar|baz)$"
required>
<span data-validator-target="errors" data-field-name="foobarbaz"></span>
</dd>
<dt>selector</dt>
<dd>
<select name="select"
data-validator-target="fields"
data-action="change->validator#validate"
required>
<option value=""></option>
<option value="foo">foo</option>
<option value="bar">bar</option>
<option value="baz">baz</option>
</select>
<span data-validator-target="errors" data-field-name="select"></span>
</dd>
<dt>category (required)</dt>
<dd>
<label><input type="checkbox" name="category[]" value="foo"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> foo</label>
<label><input type="checkbox" name="category[]" value="bar"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> bar</label>
<label><input type="checkbox" name="category[]" value="baz"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> baz</label>
<span data-validator-target="errors" data-field-name="category[]"></span>
</dd>
<dt>radio (required)</dt>
<dd>
<label><input type="radio" name="radio" value="foo"
data-validator-target="fields"
data-action="change->validator#validate"
required>foo</label>
<label><input type="radio" name="radio" value="bar"
data-validator-target="fields"
data-action="change->validator#validate"
required>bar</label>
<label><input type="radio" name="radio" value="baz"
data-validator-target="fields"
data-action="change->validator#validate"
required>baz</label>
<span data-validator-target="errors" data-field-name="radio"></span>
</dd>
<dt>input (server side validation / valid value -> zaru)</dt>
<dd>
<input type="text" name="serverside"
data-validator-target="fields"
data-action="keyup->validator#validate"
data-validate-endpoint="validation.php"
required>
<span data-validator-target="errors" data-field-name="serverside"></span>
</dd>
</dl>
<input type="submit" value="post" data-validator-target="submit">
</form>
</body>
</html>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="https://unpkg.com/stimulus/dist/stimulus.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<link href="style.css" media="all" rel="stylesheet">
<script>
(() => {
_.mixin({
memoizeDebounce: function(func, wait=0, options={}) {
var mem = _.memoize(function() {
return _.debounce(func, wait, options)
}, options.resolver);
return function(){mem.apply(this, arguments).apply(this, arguments)}
}
});
const application = Stimulus.Application.start();
application.register('switch-tab', class extends Stimulus.Controller {
static targets = ['tabContents', 'tabs'];
static classes = ['active'];
connect() {
this.tabContentsTargets.forEach((content) => {
if (content.dataset.tabDefault === 'true') {
const tabName = content.dataset.tab;
const tab = this.tabsTargets.find((tab) => tab.dataset.tab === tabName);
tab.classList.add(this.activeClass);
content.style.display = 'block';
} else {
content.style.display = 'none';
}
});
}
switch(event) {
const button = event.currentTarget;
const targetTab = button.dataset.tab;
this.tabsTargets.forEach((tab) => {
tab.classList.remove(this.activeClass);
});
button.classList.add(this.activeClass);
this.tabContentsTargets.forEach((content) => {
if (content.dataset.tab === targetTab) {
content.style.display = 'block';
} else {
content.style.display = 'none';
}
});
}
});
application.register('custom-input', class extends Stimulus.Controller {
static targets = ['select', 'input'];
connect() {
this.toggleInputParts();
this.setValue();
}
selectValue(event) {
this.toggleInputParts();
this.setValue();
}
toggleInputParts() {
if (this.selectTarget.value) {
this.inputTarget.style.display = 'none';
} else {
this.inputTarget.style.display = 'inline-block';
}
}
setValue() {
this.inputTarget.value = this.selectTarget.value;
this.inputTarget.dispatchEvent(new CustomEvent('change', { bubbles: true }));
}
});
application.register('sync-data', class extends Stimulus.Controller {
static values = {
endpoint: String
};
static classes = ['loading', 'success', 'fail'];
async sync(event) {
this.resetClass();
const input = event.currentTarget;
const data = {
[input.name]: input.value
}
const result = await fetch(this.endpointValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (result.status === 200) {
this.setSuccessClass();
} else {
this.setFailClass();
}
}
resetClass() {
this.element.classList.remove(this.successClass);
this.element.classList.remove(this.failClass);
this.element.classList.add(this.loadingClass);
}
setSuccessClass() {
this.element.classList.remove(this.loadingClass);
this.element.classList.add(this.successClass);
}
setFailClass() {
this.element.classList.remove(this.loadingClass);
this.element.classList.add(this.failClass);
}
});
application.register('editable-label', class extends Stimulus.Controller {
static targets = ['label', 'field', 'actions'];
connect() {
this.fieldTarget.style.display = 'none';
this.labelTarget.innerHTML = this.fieldTarget.value;
this.showLabel();
}
edit() {
this.showField();
}
apply() {
this.labelTarget.innerHTML = this.fieldTarget.value;
this.showLabel();
}
cancel() {
this.fieldTarget.innerHTML = this.labelTarget.value;
this.showLabel();
}
showLabel() {
this.hideActions();
this.fieldTarget.style.display = 'none';
this.labelTarget.style.display = 'inline-block';
}
showField() {
this.showActions();
this.fieldTarget.style.display = 'inline-block';
this.labelTarget.style.display = 'none';
}
showActions() {
this.actionsTarget.style.display = 'block';
}
hideActions() {
this.actionsTarget.style.display = 'none';
}
});
application.register('validator', class extends Stimulus.Controller {
static targets = ['fields', 'errors', 'submit'];
static classes = ['error'];
initialize() {
this.validate = _.memoizeDebounce(this.validate, 250)
}
connect() {
this.fieldsTargets.forEach((field) => {
const error = this.checkRule(field)
if (error) {
this.disableSubmit();
}
});
this.errorsTargets.forEach((error) => {
error.style.display = 'none';
});
}
validate(event) {
const error = this.checkRule(event.target, true);
const errorElement = this.errorsTargets.find((error) => error.dataset.fieldName === event.target.name);
if (error) {
errorElement.textContent = error;
errorElement.style.display = 'inline-block';
this.disableSubmit();
} else {
errorElement.textContent = '';
errorElement.style.display = 'none';
const otherError = this.fieldsTargets.find((field) => this.checkRule(field));
if (!otherError) {
this.enableSubmit();
}
}
}
syncCheckbox(event) {
const name = event.currentTarget.name;
const checkboxGroups = this.fieldsTargets.filter((field) => field.name === name);
const checked = checkboxGroups.find((checkbox) => checkbox.checked);
if (checked) {
checkboxGroups.forEach((checkbox) => {
checkbox.required = false;
})
} else {
checkboxGroups.forEach((checkbox) => {
checkbox.required = true;
})
}
}
checkRule(field, showError = false) {
const valid = field.checkValidity();
if (showError && !valid) {
field.classList.add(this.errorClass);
} else {
field.classList.remove(this.errorClass);
}
return field.validationMessage;
}
disableSubmit() {
this.submitTarget.disabled = true;
}
enableSubmit() {
this.submitTarget.disabled = false;
}
showError() {
console.log('showError');
}
});
})();
</script>
</head>
<body>
<h1>Stimulus example</h1>
<h2>Switch tabs</h2>
<div data-controller="switch-tab" data-switch-tab-active-class="active">
<div class="tabs">
<button type="button" data-switch-tab-target="tabs" data-tab="tab1" data-action="click->switch-tab#switch">tab1</button>
<button type="button" data-switch-tab-target="tabs" data-tab="tab2" data-action="click->switch-tab#switch">tab2</button>
<button type="button" data-switch-tab-target="tabs" data-tab="tab3" data-action="click->switch-tab#switch">tab3</button>
</div>
<div class="tab-contents" data-switch-tab-target="tabContents" data-tab="tab1" data-tab-default="true">
tab1 content
<div data-controller="switch-tab" data-switch-tab-active-class="active">
<h3>Nested tabs</h3>
<div class="tabs">
<button type="button" data-switch-tab-target="tabs" data-tab="nested-tab1" data-action="click->switch-tab#switch">nested tab1</button>
<button type="button" data-switch-tab-target="tabs" data-tab="nested-tab2" data-action="click->switch-tab#switch">nested tab2</button>
</div>
<div class="tab-contents" data-switch-tab-target="tabContents" data-tab="nested-tab1" data-tab-default="true">
nested tab1 content
</div>
<div class="tab-contents" data-switch-tab-target="tabContents" data-tab="nested-tab2">
nested tab2 content
</div>
</div>
</div>
<div class="tab-contents" data-switch-tab-target="tabContents" data-tab="tab2">
tab2 content
</div>
<div class="tab-contents" data-switch-tab-target="tabContents" data-tab="tab3">
tab3 content
</div>
</div>
<h2>Custom input</h2>
<div data-controller="custom-input">
<select data-custom-input-target="select" data-action="change->custom-input#selectValue">
<option value="30">30</option>
<option value="60">60</option>
<option value="90">90</option>
<option value="">custom</option>
</select>
<input type="text" data-custom-input-target="input">
</div>
<h2>Data sync</h2>
<h3>Success</h3>
<div data-controller="sync-data"
data-sync-data-endpoint-value="dummy_endpoint_200.php"
data-sync-data-loading-class="sync--busy"
data-sync-data-fail-class="sync--fail"
data-sync-data-success-class="sync--success"
>
<input type="text" name="name" data-action="blur->sync-data#sync">
</div>
<h3>Fail</h3>
<div data-controller="sync-data"
data-sync-data-endpoint-value="dummy_endpoint_500.php"
data-sync-data-loading-class="sync--busy"
data-sync-data-fail-class="sync--fail"
data-sync-data-success-class="sync--success"
>
<input type="text" name="name" data-action="blur->sync-data#sync">
</div>
<h3>Multiple fields</h3>
<div data-controller="sync-data"
data-sync-data-endpoint-value="dummy_endpoint_200.php"
data-sync-data-loading-class="sync--busy"
data-sync-data-fail-class="sync--fail"
data-sync-data-success-class="sync--success"
>
<input type="text" name="name1" data-action="blur->sync-data#sync">
<input type="text" name="name2" data-action="blur->sync-data#sync">
</div>
<h2>Editable label</h2>
<div class="editable__label__form" data-controller="editable-label">
<span class="editable__label" data-editable-label-target="label" data-action="click->editable-label#edit"></span>
<input class="editable__field" data-editable-label-target="field" type="text" value="init value">
<div class="editable__actions" data-editable-label-target="actions">
<button type="button" data-action="click->editable-label#apply">✅</button>
<button type="button" data-action="click->editable-label#cancel">❌</button>
</div>
</div>
<h2>Form validation</h2>
<form data-controller="validator"
data-validator-error-class="error">
<dl>
<dt>name (required)</dt>
<dd>
<input type="text"
name="name"
data-validator-target="fields"
data-action="keyup->validator#validate invalid->validator#showError"
required>
<span data-validator-target="errors" data-field-name="name"></span>
</dd>
<dt>number (required / between 1 and 10)</dt>
<dd>
<input type="number"
name="number"
data-validator-target="fields"
data-action="keyup->validator#validate"
min="1"
max="10"
required>
<span data-validator-target="errors" data-field-name="number"></span>
</dd>
<dt>email (required / email format)</dt>
<dd>
<input type="email"
name="email"
data-validator-target="fields"
data-action="keyup->validator#validate"
required>
<span data-validator-target="errors" data-field-name="email"></span>
</dd>
<dt>foo,bar,baz (required / pattern /^(foo|bar|baz)$/)</dt>
<dd>
<input type="text"
name="foobarbaz"
data-validator-target="fields"
data-action="keyup->validator#validate"
pattern="^(foo|bar|baz)$"
required>
<span data-validator-target="errors" data-field-name="foobarbaz"></span>
</dd>
<dt>selector</dt>
<dd>
<select name="select"
data-validator-target="fields"
data-action="change->validator#validate"
required>
<option value=""></option>
<option value="foo">foo</option>
<option value="bar">bar</option>
<option value="baz">baz</option>
</select>
<span data-validator-target="errors" data-field-name="select"></span>
</dd>
<dt>category (required)</dt>
<dd>
<label><input type="checkbox" name="category[]" value="foo"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> foo</label>
<label><input type="checkbox" name="category[]" value="bar"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> bar</label>
<label><input type="checkbox" name="category[]" value="baz"
data-validator-target="fields"
data-action="change->validator#syncCheckbox change->validator#validate"
required> baz</label>
<span data-validator-target="errors" data-field-name="category[]"></span>
</dd>
<dt>radio (required)</dt>
<dd>
<label><input type="radio" name="radio" value="foo"
data-validator-target="fields"
data-action="change->validator#validate"
required>foo</label>
<label><input type="radio" name="radio" value="bar"
data-validator-target="fields"
data-action="change->validator#validate"
required>bar</label>
<label><input type="radio" name="radio" value="baz"
data-validator-target="fields"
data-action="change->validator#validate"
required>baz</label>
<span data-validator-target="errors" data-field-name="radio"></span>
</dd>
</dl>
<input type="submit" value="post" data-validator-target="submit">
</form>
</body>
</html>
html, body {
color: #333;
line-height: 1.6;
font-size: 16px;
}
select, input[type=text], input[type=email], input[type=number] {
font-size: 16px;
padding: 5px 15px;
}
select.error, input[type=text].error, input[type=email].error, input[type=number].error {
border-color: red;
}
button {
padding: 5px 15px;
font-size: 18px;
}
button.active {
border-color: aqua;
}
.tab-contents {
margin: 20px auto;
padding: 20px;
border: 1px solid #999;
}
.sync--busy:after {
content: '🌎';
display: inline-block;
line-height: 1;
width: 16px;
height: 16px;
animation: rotate 1s linear infinite;
}
.sync--success:after {
content: '✅';
}
.sync--fail:after {
content: '❌';
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.editable__label__form {
display: flex;
}
.editable__label, .editable__field {
border-width: 1px;
display: inline-block;
box-sizing: border-box;
margin: 0;
padding: 5px 10px;
font-size: 16px;
font-weight: normal;
}
.editable__label {
cursor: pointer;
}
.editable__label:after {
content: '📝';
margin-left: 5px;
cursor: pointer;
}
.editable__actions button {
background: none;
border: none;
}
<?php
header("Content-Type: application/json; charset=utf-8");
$json = json_decode(file_get_contents("php://input"), true);
if ($json['serverside'] == 'zaru') {
$output = [
'result' => 'ok',
'error_message' => ''
];
} else {
$output = [
'result' => 'ng',
'error_message' => 'サーバサイドのチェックが失敗しました'
];
}
echo json_encode($output);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment