In this demo, backtracking algorithm is used for generating the sudoku. Difficulty and solvability is totally random as I randomly left a number of hints from a full-filled board.
A Pen by Siavash Habibi on CodePen.
<!-- | |
# Javascript Sudoku Puzzle Generator | |
Generation process is handled in a producer wrapped in | |
a worker that is embedded in html. | |
API is designed to be pluggable, but needs more | |
interface to support this. Currently it responds | |
with an array of rows. | |
JS code includes: | |
- a utility object, | |
- an adapter that acts as the communication layer | |
between producer and consumer (app) | |
- app singleton that includes the rendering logic | |
--> | |
<div id='sudoku-app'></div> | |
<!-- https://github.com/scriptype/bem --> | |
<script type='text/javascript'> | |
"use strict";!function(a){if("function"==typeof bootstrap)bootstrap("bem",a);else if("object"==typeof exports&&"object"==typeof module)module.exports=a();else if("function"==typeof define&&define.amd)define(a);else if("undefined"!=typeof ses){if(!ses.ok())return;ses.makeBem=a}else{if("undefined"==typeof window&&"undefined"==typeof self)throw new Error("This environment was not anticipated by bem. Please file a bug.");var b="undefined"!=typeof window?window:self,c=b.bem;b.bem=a(),b.bem.noConflict=function(){return b.bem=c,this}}}(function(){function a(a){"undefined"!=typeof a.modifier&&(c.modifier=a.modifier),"undefined"!=typeof a.element&&(c.element=a.element)}function b(a){if(!d.validate(a))return null;var b=a.block,e=a.element,f=a.modifiers,g=b,h=[];return!!e&&(g+=""+c.element+e),!!f&&Object.keys(f).forEach(function(a){var d=f[a],i="function"==typeof d?d(b,e,f):d;!!i&&h.push(""+g+c.modifier+a+" ")}),(g+" "+h.join("")).slice(0,-1)}var c={element:"__",modifier:"--"},d={messages:{block:"You must specify the name of block.",element:"Element name must be a string.",modifier:"Modifiers must be supplied in the `{name : bool || fn}` style."},blockName:function(a){return"undefined"!=typeof a&&"string"==typeof a&&a.length?!0:(console.warn(this.messages.block),!1)},element:function(a){return"undefined"!=typeof a&&"string"!=typeof a?(console.warn(this.messages.element),!1):!0},modifiers:function(a){return"undefined"==typeof a||"object"==typeof a&&"[object Object]"===toString.call(a)?!0:(console.warn(this.messages.modifier),!1)},validate:function(a){return this.blockName(a.block)&&this.element(a.element)&&this.modifiers(a.modifiers)}};return{setDelimiters:a,makeClassName:b}}); | |
</script> | |
<!-- Include Babel to transform code in browser --> | |
<script type='text/javascript' | |
src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.25/browser-polyfill.min.js'> | |
</script> | |
<script type='text/javascript' | |
src='https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.25/browser.min.js'> | |
</script> | |
<!-- worker, sudoku api --> | |
<script type='text/babel' id='worker'> | |
self.sudoku = null | |
// Worker Setup | |
self.addEventListener('message', (event) => { | |
var options = { method: null } | |
try { | |
options = JSON.parse(event.data); | |
} catch (e) { | |
console.warn('event.data is misformed', event) | |
} | |
switch (options.method) { | |
case 'generate': | |
var { hints, limit } = options | |
self.sudoku = new Sudoku(hints, limit).generate() | |
self.postMessage({ | |
success: self.sudoku.success, | |
board: self.sudoku.getBoard(), | |
solution: self.sudoku.getSolution() | |
}); | |
break; | |
case 'validate': | |
var { map, number, index } = options | |
self.postMessage({ | |
result: sudoku.validate(map, number, index) | |
}); | |
break; | |
} | |
}, false); | |
// API | |
class Sudoku { | |
constructor(hints, limit) { | |
this.hints = hints | |
this.limit = limit || 10000 | |
this._logs = { | |
raw: [], | |
incidents: { | |
limitExceeded: 0, | |
notValid: 0, | |
noNumbers: 0 | |
} | |
} | |
this.success = null | |
this.numbers = () => | |
new Array(9) | |
.join(" ") | |
.split(" ") | |
.map((num , i) => i + 1) | |
/* | |
Will be used in initial map. Each row will be | |
consisted of randomly ordered numbers | |
*/ | |
this.randomRow = () => { | |
var row = [] | |
var numbers = this.numbers() | |
while (row.length < 9) { | |
var index = Math.floor(Math.random() * numbers.length) | |
row.push(numbers[index]) | |
numbers.splice(index, 1) | |
} | |
return row | |
} | |
/* | |
This is the dummy placeholder for the | |
final results. Will be overridden through the | |
backtracking process, and at the and, this will | |
be the real results. | |
*/ | |
this.result = new Array(9 * 9) | |
.join(" ") | |
.split(" ") | |
.map(entry => null) | |
/* | |
Will be used as the nodeTree in the | |
process of backtracking. Each cell has 9 alternative | |
paths (randomly ordered). | |
*/ | |
this.map = new Array(9 * 9) | |
.join(" ") | |
.split(" ") | |
.map(path => this.randomRow()) | |
/* | |
Will be used as history in the backtracking | |
process for checking if a candidate number is valid. | |
*/ | |
this.stack = [] | |
return this | |
} | |
toRows(arr) { | |
var row = 0 | |
var asRows = new Array(9) | |
.join(" ") | |
.split(" ") | |
.map(row => []) | |
for (let [index, entry] of arr.entries()) { | |
asRows[row].push(entry) | |
if ( !((index + 1) % 9) ) { | |
row += 1 | |
} | |
} | |
return asRows | |
} | |
no(path, index, msg) { | |
var number = path[path.length - 1] | |
this._logs.raw.push(`no: @${index} [${number}] ${msg} ${path} `) | |
} | |
yes(path, index) { | |
this._logs.raw.push(`yes: ${index} ${path}`) | |
} | |
finalLog() { | |
console.groupCollapsed('Raw Logs') | |
console.groupCollapsed(this._logs.raw) | |
console.groupEnd() | |
console.groupEnd() | |
console.groupCollapsed('Incidents') | |
console.groupCollapsed(this._logs.incidents) | |
console.groupEnd() | |
console.groupEnd() | |
} | |
getBoard() { | |
return this.toRows(this.substractCells()) | |
} | |
getSolution() { | |
return this.toRows(this.result) | |
} | |
substractCells() { | |
var _getNonEmptyIndex = () => { | |
var index = Math.floor(Math.random() * _result.length) | |
return _result[index] ? index : _getNonEmptyIndex() | |
} | |
var _result = this.result.filter(() => true) | |
while ( | |
_result.length - this.hints > | |
_result.filter(n => !n).length | |
) { | |
_result[_getNonEmptyIndex()] = '' | |
} | |
return _result | |
} | |
validate(map, number, index) { | |
var rowIndex = Math.floor(index / 9) | |
var colIndex = index % 9 | |
var row = map.slice( | |
rowIndex * 9, 9 * (rowIndex + 1) | |
) | |
var col = map.filter((e, i) => | |
i % 9 === colIndex | |
) | |
var boxRow = Math.floor(rowIndex / 3) | |
var boxCol = Math.floor(colIndex / 3) | |
var box = map.filter((e, i) => | |
Math.floor(Math.floor(i / 9) / 3) === boxRow && | |
Math.floor((i % 9) / 3) === boxCol | |
) | |
return { | |
row: { | |
first: row.indexOf(number), | |
last: row.lastIndexOf(number) | |
}, | |
col: { | |
first: col.indexOf(number), | |
last: col.lastIndexOf(number) | |
}, | |
box: { | |
first: box.indexOf(number), | |
last: box.lastIndexOf(number) | |
} | |
} | |
} | |
_validate(map, index) { | |
if (!map[index].length) { | |
return false | |
} | |
this.stack.splice(index, this.stack.length) | |
var path = map[index] | |
var number = path[path.length - 1] | |
var didFoundNumber = this.validate(this.stack, number, index) | |
return ( | |
didFoundNumber.col.first === -1 && | |
didFoundNumber.row.first === -1 && | |
didFoundNumber.box.first === -1 | |
) | |
} | |
_generate(map, index) { | |
if (index === 9 * 9) { | |
return true | |
} | |
if (--this.limit < 0) { | |
this._logs.incidents.limitExceeded++ | |
this.no(map[index], index, 'limit exceeded') | |
return false | |
} | |
var path = map[index] | |
if (!path.length) { | |
map[index] = this.numbers() | |
map[index - 1].pop() | |
this._logs.incidents.noNumbers++ | |
this.no(path, index, 'no numbers in it') | |
return false | |
} | |
var currentNumber = path[path.length - 1] | |
var isValid = this._validate(map, index) | |
if (!isValid) { | |
map[index].pop() | |
map[index + 1] = this.numbers() | |
this._logs.incidents.notValid++ | |
this.no(path, index, 'is not valid') | |
return false | |
} else { | |
this.stack.push(currentNumber) | |
} | |
for (let number of path.entries()) { | |
if (this._generate(map, index + 1)) { | |
this.result[index] = currentNumber | |
this.yes(path, index) | |
return true | |
} | |
} | |
return false | |
} | |
generate() { | |
if (this._generate(this.map, 0)) { | |
this.success = true | |
} | |
this.finalLog() | |
return this | |
} | |
} | |
</script> |
In this demo, backtracking algorithm is used for generating the sudoku. Difficulty and solvability is totally random as I randomly left a number of hints from a full-filled board.
A Pen by Siavash Habibi on CodePen.
// Utility | |
var utils = (() => { | |
function dom (selector) { | |
if (selector[0] === '#') { | |
return document.getElementById(selector.slice(1)) | |
} | |
return document.querySelectorAll(selector) | |
} | |
function copyJSON (obj) { | |
return JSON.parse(JSON.stringify(obj)) | |
} | |
function isTouchDevice () { | |
return navigator.userAgent | |
.match(/(iPhone|iPod|iPad|Android|BlackBerry)/) | |
} | |
function getWorkerURLFromElement(selector) { | |
var element = dom(selector) | |
var content = babel.transform(element.innerText).code | |
var blob = new Blob([content], {type: 'text/javascript'}) | |
return URL.createObjectURL(blob) | |
} | |
// Will be used for restoring caret positions on rerenders. | |
// Taken from: | |
// http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity | |
var cursorManager = (function () { | |
var cursorManager = {} | |
var voidNodeTags = [ | |
'AREA', 'BASE', 'BR', 'COL', 'EMBED', | |
'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', | |
'MENUITEM', 'META', 'PARAM', 'SOURCE', | |
'TRACK', 'WBR', 'BASEFONT', 'BGSOUND', | |
'FRAME', 'ISINDEX' | |
]; | |
Array.prototype.contains = function(obj) { | |
var i = this.length; | |
while (i--) { | |
if (this[i] === obj) { | |
return true; | |
} | |
} | |
return false; | |
} | |
function canContainText(node) { | |
if(node.nodeType == 1) { | |
return !voidNodeTags.contains(node.nodeName); | |
} else { | |
return false; | |
} | |
}; | |
function getLastChildElement(el){ | |
var lc = el.lastChild; | |
while(lc && lc.nodeType != 1) { | |
if(lc.previousSibling) | |
lc = lc.previousSibling; | |
else | |
break; | |
} | |
return lc; | |
} | |
cursorManager.setEndOfContenteditable = function(contentEditableElement) { | |
while(getLastChildElement(contentEditableElement) && | |
canContainText(getLastChildElement(contentEditableElement))) { | |
contentEditableElement = getLastChildElement(contentEditableElement); | |
} | |
var range,selection; | |
if(document.createRange) { | |
range = document.createRange(); | |
range.selectNodeContents(contentEditableElement); | |
range.collapse(false); | |
selection = window.getSelection(); | |
selection.removeAllRanges(); | |
selection.addRange(range); | |
} | |
else if(document.selection) | |
{ | |
range = document.body.createTextRange(); | |
range.moveToElementText(contentEditableElement); | |
range.collapse(false); | |
range.select(); | |
} | |
} | |
return cursorManager | |
})() | |
return { | |
copyJSON, cursorManager, dom, | |
getWorkerURLFromElement, isTouchDevice | |
} | |
})(); | |
// API Adapter | |
class SudokuAdapter { | |
constructor(url) { | |
this.worker = new Worker(url) | |
return this | |
} | |
_postMessage(options) { | |
this.worker.postMessage(JSON.stringify(options)) | |
return new Promise((resolve, reject) => { | |
this.worker.onmessage = event => { | |
resolve(event.data) | |
} | |
}) | |
} | |
generate(options) { | |
options = Object.assign | |
({}, options, { method: 'generate' }) | |
return this._postMessage(options) | |
} | |
validate(options) { | |
options = Object.assign | |
({}, options, { method: 'validate' }) | |
return this._postMessage(options) | |
} | |
} | |
// Client Side Settings | |
const SUDOKU_APP_CONFIG = { | |
HINTS: 34, | |
TRY_LIMIT: 100000, | |
WORKER_URL: utils.getWorkerURLFromElement('#worker'), | |
DOM_TARGET: utils.dom('#sudoku-app') | |
} | |
// Client Side | |
var SudokuApp = (config => { | |
const { | |
HINTS, TRY_LIMIT, | |
WORKER_URL, DOM_TARGET | |
} = config | |
var sudokuAdapter = new SudokuAdapter(WORKER_URL) | |
var state = { | |
success: null, | |
board: null, | |
solution: null, | |
solved: null, | |
errors: [] | |
}; | |
Object.observe(state, render) | |
var history = [state] | |
var historyStash = [] | |
// Event listeners | |
var onClickGenerate = initialize | |
var onClickSolve = function () { | |
setState({ | |
board: state.solution, | |
solved: true, | |
errors: [] | |
}) | |
} | |
var onKeyUpCell = function (event) { | |
var key = event.keyCode | |
if ( // a | |
key === 36 || // r | |
key === 37 || // r | |
key === 38 || // o | |
key === 39 || // w | |
key === 9 || // tab | |
// mod key flags are always false in keyup event | |
// keyIdentifier doesn't seem to be implemented | |
// in all browsers | |
key === 17 || // Control | |
key === 16 || // Shift | |
key === 91 || // Meta | |
key === 19 || // Alt | |
event.keyIdentifier === 'Control' || | |
event.keyIdentifier === 'Shift' || | |
event.keyIdentifier === 'Meta' || | |
event.keyIdentifier === 'Alt' | |
) return | |
var cell = event.target | |
var value = cell.innerText | |
if (value.length > 4) { | |
cell.innerText = value.slice(0, 4) | |
return false | |
} | |
var cellIndex = cell.getAttribute('data-cell-index') | |
cellIndex = parseInt(cellIndex, 10) | |
var rowIndex = Math.floor(cellIndex / 9) | |
var cellIndexInRow = cellIndex - (rowIndex * 9) | |
var board = Object.assign([], state.board) | |
board[rowIndex].splice(cellIndexInRow, 1, value) | |
validate(board).then(errors => { | |
historyStash = [] | |
history.push({}) | |
var solved = null | |
if (errors.indexOf(true) === -1) { | |
solved = true | |
board.forEach(row => { | |
row.forEach(value => { | |
if (!value || !parseInt(value, 10) || value.length > 1) { | |
solved = false | |
} | |
}) | |
}) | |
} | |
if (solved) { | |
board = Object.assign([], board).map(row => row.map(n => +n)) | |
} | |
setState({ board, errors, solved }, (newState) => { | |
history[history.length - 1] = newState | |
restoreCaretPosition(cellIndex) | |
}) | |
}) | |
} | |
function keyDown (event) { | |
var keys = { | |
ctrlOrCmd: event.ctrlKey || event.metaKey, | |
shift: event.shiftKey, | |
z: event.keyCode === 90 | |
} | |
if (keys.ctrlOrCmd && keys.z) { | |
if (keys.shift && historyStash.length) { | |
redo() | |
} else if (!keys.shift && history.length > 1) { | |
undo() | |
} | |
} | |
} | |
function undo () { | |
historyStash.push(history.pop()) | |
setState(utils.copyJSON(history[history.length - 1])) | |
} | |
function redo () { | |
history.push(historyStash.pop()) | |
setState(utils.copyJSON(history[history.length - 1])) | |
} | |
function initialize () { | |
unbindEvents() | |
render() | |
getSudoku().then(sudoku => { | |
setState({ | |
success: sudoku.success, | |
board: sudoku.board, | |
solution: sudoku.solution, | |
errors: [], | |
solved: false | |
}, newState => { | |
history = [newState] | |
historyStash = [] | |
}) | |
}) | |
} | |
function setState(newState, callback) { | |
requestAnimationFrame(() => { | |
Object.assign(state, newState) | |
if (typeof callback === 'function') { | |
var param = utils.copyJSON(state) | |
requestAnimationFrame(callback.bind(null, param)) | |
} | |
}) | |
} | |
function bindEvents() { | |
var generateButton = utils.dom('#generate-button') | |
var solveButton = utils.dom('#solve-button') | |
var undoButton = utils.dom('#undo-button') | |
var redoButton = utils.dom('#redo-button') | |
generateButton && | |
generateButton | |
.addEventListener('click', onClickGenerate) | |
solveButton && | |
solveButton | |
.addEventListener('click', onClickSolve) | |
undoButton && | |
undoButton | |
.addEventListener('click', undo) | |
redoButton && | |
redoButton | |
.addEventListener('click', redo) | |
var cells = utils.dom('.sudoku__table-cell') | |
;[].forEach.call(cells, (cell) => { | |
cell.addEventListener('keyup', onKeyUpCell) | |
}) | |
window.addEventListener('keydown', keyDown) | |
} | |
function unbindEvents() { | |
var generateButton = utils.dom('#generate-button') | |
var solveButton = utils.dom('#solve-button') | |
var undoButton = utils.dom('#undo-button') | |
var redoButton = utils.dom('#redo-button') | |
generateButton && | |
generateButton | |
.removeEventListener('click', onClickGenerate) | |
solveButton && | |
solveButton | |
.removeEventListener('click', onClickSolve) | |
undoButton && | |
undoButton | |
.removeEventListener('click', undo) | |
redoButton && | |
redoButton | |
.removeEventListener('click', redo) | |
var cells = utils.dom('.sudoku__table-cell') | |
;[].forEach.call(cells, (cell) => { | |
cell.removeEventListener('keyup', onKeyUpCell) | |
}) | |
window.removeEventListener('keydown', keyDown) | |
} | |
function restoreCaretPosition(cellIndex) { | |
utils.cursorManager.setEndOfContenteditable( | |
utils.dom(`[data-cell-index="${ cellIndex }"]`)[0] | |
) | |
} | |
function getSudoku() { | |
return sudokuAdapter.generate({ | |
hints: HINTS, | |
limit: TRY_LIMIT | |
}) | |
} | |
function validate(board) { | |
var map = board.reduce((memo, row) => { | |
for (let num of row) { | |
memo.push(num) | |
} | |
return memo | |
}, []).map((num) => parseInt(num, 10)) | |
var validations = [] | |
// Will validate one by one | |
for (let [index, number] of map.entries()) { | |
if (!number) { | |
validations.push( | |
new Promise(res => { | |
res({ result: { box: -1, col: -1, row: -1 } }) | |
}) | |
) | |
} else { | |
let all = Promise.all(validations) | |
validations.push(all.then(() => { | |
return sudokuAdapter.validate({map, number, index}) | |
})) | |
} | |
} | |
return Promise.all(validations) | |
.then(values => { | |
var errors = [] | |
for (let [index, validation] of values.entries()) { | |
let { box, col, row } = validation.result | |
let errorInBox = box.first !== box.last | |
let errorInCol = col.first !== col.last | |
let errorInRow = row.first !== row.last | |
let indexOfRow = Math.floor(index / 9) | |
let indexInRow = index - (indexOfRow * 9) | |
errors[index] = errorInRow || errorInCol || errorInBox | |
} | |
return errors | |
}) | |
} | |
function render() { | |
unbindEvents() | |
DOM_TARGET.innerHTML = ` | |
<div class='sudoku'> | |
${ headerComponent() } | |
${ contentComponent() } | |
</div> | |
` | |
bindEvents() | |
} | |
function buttonComponent(props) { | |
var { id, text, mods, classes } = props | |
var blockName = 'button' | |
var modifiers = {} | |
var modType = toString.call(mods) | |
if (modType === '[object String]') { | |
modifiers[mods] = true | |
} else if (modType === '[object Array]') { | |
for (let modName of mods) { | |
modifiers[modName] = true | |
} | |
} | |
var blockClasses = bem.makeClassName({ | |
block: blockName, | |
modifiers: modifiers | |
}); | |
var buttonTextClass = `${blockName}-text` | |
if (Object.keys(modifiers).length) { | |
buttonTextClass += ( | |
Object.keys(modifiers).reduce((memo, curr) => { | |
return memo + ` ${blockName}--${curr}-text` | |
}, '') | |
) | |
} | |
var lgText = typeof text === 'string' ? | |
text : text[0] | |
var mdText = typeof text === 'string' ? | |
text : text[1] | |
var smText = typeof text === 'string' ? | |
text : text[2] | |
return (` | |
<button | |
id='${ id }' | |
class='${ blockClasses } ${ classes || "" }'> | |
<span class='show-on-sm ${buttonTextClass}'> | |
${ smText } | |
</span> | |
<span class='show-on-md ${buttonTextClass}'> | |
${ mdText } | |
</span> | |
<span class='show-on-lg ${buttonTextClass}'> | |
${ lgText } | |
</span> | |
</button> | |
`) | |
} | |
function messageComponent(options) { | |
var { state, content } = options | |
var messageClass = bem.makeClassName({ | |
block: 'message', | |
modifiers: state ? { | |
[state]: true | |
} : {} | |
}) | |
return (` | |
<p class='${ messageClass }'> | |
${ content } | |
</p> | |
`) | |
} | |
function descriptionComponent(options) { | |
var { className, infoLevel } = options | |
var technical = ` | |
In this demo, | |
<a href='https://en.wikipedia.org/wiki/Backtracking'> | |
backtracking algorithm | |
</a> is used for <em>generating</em> | |
the sudoku.` | |
var description = ` | |
Difficulty and solvability is | |
totally random as I randomly left a certain number of hints | |
from a full-filled board. | |
` | |
if (infoLevel === 'full') { | |
return (` | |
<p class='${ className || '' }'> | |
${ technical } ${ description } | |
</p> | |
`) | |
} else if (infoLevel === 'mini') { | |
return (` | |
<p class='${ className || '' }'> | |
${ description } | |
</p> | |
`) | |
} | |
} | |
function restoreScrollPosComponent() { | |
return `<div style='height: 540px'></div>` | |
} | |
function headerComponent() { | |
return (` | |
<div class='sudoku__header'> | |
<h1 class='sudoku__title'> | |
<span class='show-on-sm'> | |
Sudoku | |
</span> | |
<span class='show-on-md'> | |
Sudoku Puzzle | |
</span> | |
<span class='show-on-lg'> | |
Javascript Sudoku Puzzle Generator | |
</span> | |
</h1> | |
${descriptionComponent({ | |
infoLevel: 'mini', | |
className: 'sudoku__description show-on-md' | |
})} | |
${descriptionComponent({ | |
infoLevel: 'full', | |
className: 'sudoku__description show-on-lg' | |
})} | |
${ | |
state.success ? (` | |
${buttonComponent({ | |
id: 'generate-button', | |
text: ['New Board', 'New Board', 'New'], | |
mods: 'primary' | |
})} | |
${ state.solved ? | |
buttonComponent({ | |
id: 'solve-button', | |
text: 'Solved', | |
mods: ['tertiary', 'muted'] | |
}) : | |
buttonComponent({ | |
id: 'solve-button', | |
text: 'Solve', | |
mods: 'secondary' | |
}) | |
} | |
`) | |
: (` | |
${buttonComponent({ | |
id: 'generate-button', | |
text: ['Generating', '', ''], | |
mods: ['disabled', 'loading'] | |
})} | |
${buttonComponent({ | |
id: 'solve-button', | |
text: 'Solve', | |
mods: 'disabled' | |
})} | |
`) | |
} | |
${ utils.isTouchDevice() ? (` | |
${buttonComponent({ | |
id: 'redo-button', | |
text: ['»', '»', '>', '>'], | |
classes: 'fr', | |
mods: [ | |
'neutral', | |
'compound', | |
'compound-last', | |
`${ !historyStash.length ? | |
'disabled' : | |
'' | |
}` | |
] | |
})} | |
${buttonComponent({ | |
id: 'undo-button', | |
text: ['«', '«', '<', '<'], | |
classes: 'fr', | |
mods: [ | |
'neutral', | |
'compound', | |
'compound-first', | |
`${ history.length > 1 ? | |
'' : | |
'disabled' | |
}` | |
] | |
})} | |
`) : ''} | |
</div> | |
`) | |
} | |
function contentComponent() { | |
var _isSeparator = (index) => | |
!!index && !((index + 1) % 3) | |
var resultReady = !!state.board | |
var fail = resultReady && !state.success | |
if (!resultReady) { | |
return (` | |
${messageComponent({ | |
state: 'busy', | |
content: `Generating new board...` | |
})} | |
${ restoreScrollPosComponent() } | |
`) | |
} | |
if (fail) { | |
return (` | |
${messageComponent({ | |
state: 'fail', | |
content: `Something went wrong with this board, try generating another one.` | |
})} | |
${ restoreScrollPosComponent() } | |
`) | |
} | |
var rows = state.board | |
return (` | |
<table class='sudoku__table'> | |
${rows.map((row, index) => { | |
let className = bem.makeClassName({ | |
block: 'sudoku', | |
element: 'table-row', | |
modifiers: { | |
separator: _isSeparator(index) | |
} | |
}); | |
return ( | |
`<tr class='${ className }'> | |
${row.map((num, _index) => { | |
let cellIndex = (index * 9) + _index | |
let separator = _isSeparator(_index) | |
let editable = typeof num !== 'number' | |
let error = state.errors[cellIndex] | |
let className = bem.makeClassName({ | |
block: 'sudoku', | |
element: 'table-cell', | |
modifiers: { | |
separator, | |
editable, | |
error, | |
'editable-error': editable && error | |
} | |
}); | |
return ( | |
`\n\t | |
<td class='${ className }' | |
data-cell-index='${ cellIndex }' | |
${ editable ? 'contenteditable' : ''}> | |
${ num } | |
</td>` | |
) | |
}).join('')} | |
\n</tr>\n` | |
) | |
}).join('')} | |
</table> | |
`) | |
} | |
return { initialize } | |
})( SUDOKU_APP_CONFIG ).initialize() |
<script src="https://cdn.rawgit.com/MaxArt2501/object-observe/master/dist/object-observe-lite.min.js"></script> |
/**********************************\ | |
--------- Sudoku App Styles -------- | |
Contents: | |
- Global definitions | |
- Modules | |
Each module may have its own | |
variables, mixins, animations and | |
media queries. | |
This is the convention followed | |
in structuring rules | |
// ================== | |
// Foo | |
// ================== | |
// Variables (Variables of Foo) | |
[...] | |
// Lorem (Lorem of foo) | |
[...] | |
// ================== | |
// Bar | |
// ================== | |
[...] | |
\**********************************/ | |
// ================== | |
// Global | |
// ================== | |
// Fonts | |
@font-face { | |
src: url("http://enes.in/GillSansTr-LightNr.otf"); | |
font-family: Gill; | |
font-weight: 100 | |
} | |
@font-face { | |
src: url("http://enes.in/GillSansTr-Normal.otf"); | |
font-family: Gill; | |
font-weight: 300 | |
} | |
@font-face { | |
src: url("http://enes.in/GillSansTr-Bold.otf"); | |
font-family: Gill; | |
font-weight: 600 | |
} | |
@font-face { | |
src: url("http://enes.in/GillSansTr-ExtraBold.otf"); | |
font-family: Gill; | |
font-weight: 700 | |
} | |
@font-face { | |
src: url("http://enes.in/GillSansTr-UltraBold.otf"); | |
font-family: Gill; | |
font-weight: 900 | |
} | |
html, body { | |
width: 100%; | |
height: 100% | |
} | |
body { | |
margin: 0; | |
background: #f0f0f0 | |
} | |
// Variables | |
$g-transition-duration: .2s; | |
$g-breakpoint-xs: 260px; | |
$g-breakpoint-sm: 420px; | |
$g-breakpoint-md: 615px; | |
// Responsive | |
@media (max-width: $g-breakpoint-xs) { | |
.show-on-sm { display: none; } | |
.show-on-md { display: none; } | |
.show-on-lg { display: none; } | |
.show-on-xs { display: block; } | |
} | |
@media (max-width: $g-breakpoint-sm) { | |
.show-on-xs { display: none; } | |
.show-on-md { display: none; } | |
.show-on-lg { display: none; } | |
.show-on-sm { display: block; } | |
} | |
@media (min-width: $g-breakpoint-sm + 1) | |
and (max-width: $g-breakpoint-md) { | |
.show-on-xs { display: none; } | |
.show-on-sm { display: none; } | |
.show-on-lg { display: none; } | |
.show-on-md { display: block; } | |
} | |
@media (min-width: $g-breakpoint-md) { | |
.show-on-xs { display: none; } | |
.show-on-sm { display: none; } | |
.show-on-md { display: none; } | |
.show-on-lg { display: block; } | |
} | |
// Animations | |
@keyframes progress { | |
0% { | |
box-shadow: none; | |
} | |
25% { | |
box-shadow: 2px -2px 0 1px; | |
} | |
50% { | |
box-shadow: 2px -2px 0 1px, | |
7px -2px 0 1px; | |
} | |
100% { | |
box-shadow: 2px -2px 0 1px, | |
7px -2px 0 1px, | |
12px -2px 0 1px; | |
} | |
} | |
.fr { float: right; } | |
.fl { float: left; } | |
// ================== | |
// Modules | |
// ================== | |
// ================== | |
// CTA Button | |
// ================== | |
// Variables | |
$button-primary-color: lighten(desaturate(blue, 35%), 5%); | |
$button-secondary-color: lighten(desaturate(red, 35%), 5%); | |
$button-tertiary-color: #2ECC40; | |
$button-neutral-color: #333; | |
$button-disabled-color: #bbb; | |
$button-border-radius: 3px; | |
// Mixins | |
@mixin button-base( | |
$font-size, | |
$margin, | |
$padding, | |
$loading-padding-right) { | |
.button { | |
padding: $padding; | |
font-size: $font-size; | |
&:not(:last-of-type) { | |
margin-right: $margin; | |
} | |
&--loading { | |
padding-right: $loading-padding-right | |
} | |
} | |
} | |
// Responsive | |
@media (max-width: $g-breakpoint-xs) { | |
@include button-base( | |
.6em, | |
.15em, | |
.25em .5em, | |
1.5em); | |
} | |
@media (min-width: $g-breakpoint-xs + 1) | |
and (max-width: $g-breakpoint-sm) { | |
@include button-base( | |
.75em, | |
.25em, | |
.25em .5em .15em, | |
1.5em); | |
} | |
@media (min-width: $g-breakpoint-sm + 1) | |
and (max-width: $g-breakpoint-md) { | |
@include button-base( | |
.9em, | |
.5em, | |
.5em .75em .4em, | |
1.5em); | |
} | |
@media (min-width: $g-breakpoint-md) { | |
@include button-base( | |
1em, | |
.75em, | |
.75em 1em .6em, | |
1.5em); | |
} | |
// Component | |
.button { | |
border: 1px solid; | |
font-weight: normal; | |
border-radius: $button-border-radius; | |
background: none; | |
box-shadow: none; | |
transition: all $g-transition-duration; | |
&--primary { | |
color: $button-primary-color; | |
font-weight: 600; | |
&:hover, | |
&:focus, | |
&:active { | |
border-color: $button-primary-color; | |
background: $button-primary-color; | |
} | |
&:focus { | |
box-shadow: 0 0 5px $button-primary-color; | |
} | |
} | |
&--secondary { | |
color: $button-secondary-color; | |
&:hover, | |
&:focus, | |
&:active { | |
border-color: $button-secondary-color; | |
background: $button-secondary-color; | |
} | |
&:focus { | |
box-shadow: 0 0 5px $button-secondary-color; | |
} | |
} | |
&--tertiary { | |
color: #fff; | |
border-color: $button-tertiary-color; | |
background: $button-tertiary-color; | |
} | |
&--neutral { | |
color: $button-neutral-color; | |
&:hover, | |
&:focus, | |
&:active { | |
border-color: $button-neutral-color; | |
background: $button-neutral-color; | |
} | |
&:focus { | |
box-shadow: 0 0 5px $button-neutral-color; | |
} | |
} | |
&--compound { | |
border-radius: 0; | |
border-right: none; | |
&-first { | |
border-bottom-left-radius: $button-border-radius; | |
border-top-left-radius: $button-border-radius; | |
} | |
&-last { | |
border-bottom-right-radius: $button-border-radius; | |
border-top-right-radius: $button-border-radius; | |
border-right: 1px solid; | |
} | |
} | |
&--muted { | |
pointer-events: none; | |
} | |
&--disabled { | |
border-color: $button-disabled-color; | |
color: $button-disabled-color; | |
pointer-events: none; | |
} | |
&--loading { | |
&-text::after { | |
display: inline-block; | |
width: 1px; | |
height: 1px; | |
content: ''; | |
box-shadow: 2px -2px 1px 0; | |
animation: progress 1s infinite; | |
} | |
} | |
&:hover, | |
&:focus, | |
&:active { | |
color: #fff; | |
} | |
&:focus { | |
outline: none; | |
} | |
&:active { | |
box-shadow: inset 0 -2px 10px rgba(#000, .4); | |
} | |
} | |
// ================== | |
// Feedback Messages | |
// ================== | |
// Component | |
.message { | |
font-size: .9em; | |
padding: 2em; | |
margin: 0; | |
border-radius: 3px; | |
color: rgba(black, .75); | |
&--busy { | |
background: rgba(blue, .1) | |
} | |
&--fail { | |
background: rgba(red, .1) | |
} | |
} | |
// ================== | |
// Sudoku Table | |
// ================== | |
// Variables | |
$sudoku-color: #444; | |
// Mixins | |
@mixin sudoku-base( | |
$thin-border, | |
$thick-border, | |
$cell-size, | |
$font-size, | |
$title-size, | |
$padding-around, | |
$header-padding | |
) { | |
.sudoku { | |
margin: 0 auto; | |
padding-top: $padding-around; | |
padding-bottom: $padding-around; | |
&__header { | |
padding-bottom: $header-padding | |
} | |
&__title { | |
font-size: $title-size | |
} | |
&__table { | |
font-size: $font-size; | |
border-top: $thick-border; | |
border-left: $thick-border; | |
border-collapse: collapse; | |
&-row { | |
border-bottom: $thin-border; | |
border-right: $thick-border; | |
&--separator { | |
border-bottom: $thick-border; | |
} | |
} | |
&-cell { | |
width: $cell-size; | |
height: $cell-size; | |
border-right: $thin-border; | |
&--separator { | |
border-right: $thick-border; | |
} | |
} | |
} | |
} | |
} | |
// Responsive | |
@media (max-width: $g-breakpoint-xs) { | |
@include sudoku-base( | |
1px solid $sudoku-color, | |
2px solid $sudoku-color, | |
16px, .9em, 1em, .5em, .6em); | |
.sudoku { | |
max-width: calc(#{$g-breakpoint-xs} / 1.5); | |
min-width: calc(#{$g-breakpoint-xs} / 2); | |
} | |
} | |
@media (min-width: $g-breakpoint-xs + 1) | |
and (max-width: $g-breakpoint-sm) { | |
@include sudoku-base( | |
1px solid $sudoku-color, | |
3px solid $sudoku-color, | |
32px, 1.2em, 1.2em, 1em, .9em); | |
.sudoku { | |
width: $g-breakpoint-xs; | |
} | |
} | |
@media (min-width: $g-breakpoint-sm + 1) | |
and (max-width: $g-breakpoint-md) { | |
@include sudoku-base( | |
1px solid $sudoku-color, | |
4px solid $sudoku-color, | |
48px, 1.5em, 1.5em, 2em, 1.3em); | |
.sudoku { | |
width: $g-breakpoint-sm | |
} | |
} | |
@media (min-width: $g-breakpoint-md) { | |
@include sudoku-base( | |
2px solid $sudoku-color, | |
6px solid $sudoku-color, | |
64px, 1.75em, 2em, 3em, 1.618em); | |
.sudoku { | |
width: $g-breakpoint-md | |
} | |
} | |
// Component | |
.sudoku { | |
color: $sudoku-color; | |
&__header { | |
font-family: Gill, sans-serif; | |
} | |
&__title { | |
font-weight: 600 | |
} | |
&__description { | |
max-width: 640px; | |
line-height: 1.4; | |
font-weight: 100 | |
} | |
&__table { | |
background: #fff; | |
&-row {} | |
&-cell { | |
overflow: hidden; | |
text-align: center; | |
transition: all .25s; | |
&--editable { | |
color: desaturate(blue, 25); | |
&:focus { | |
background: rgba(blue, .1); | |
outline: none; | |
} | |
} | |
&--error { | |
color: red; | |
background: #fdd; | |
} | |
&--editable-error { | |
text-shadow: 0 0 15px; | |
&:focus { | |
color: #eee; | |
background: #f45; | |
} | |
} | |
} | |
} | |
} |